@usman404/crowjs 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,838 @@
1
+ import { UIComponent } from './UIComponent.js';
2
+ import { Component } from '../Core/Component.js';
3
+
4
+ export class TextComponent extends UIComponent {
5
+ /**
6
+ * Creates a UI component with shared text behavior
7
+ * @param {number} x - The x-coordinate
8
+ * @param {number} y - The y-coordinate
9
+ * @param {number} width - The width
10
+ * @param {number} height - The height
11
+ * @param {string} label - The text to display
12
+ * @param {Object} options - Configuration options
13
+ * @param {string|null} options.id - Component ID
14
+ * @param {Component|null} options.parent - Parent component
15
+ * @param {p5.Color} options.backgroundColor - Background color
16
+ * @param {p5.Color} options.textColor - Text color
17
+ * @param {boolean} options.borderFlag - Whether to show border
18
+ * @param {p5.Color} options.borderColor - Border color
19
+ * @param {number} options.borderWidth - Border width
20
+ * @param {number} options.cornerRadius - Corner radius
21
+ * @param {boolean} options.enableShadow - Enable shadow rendering
22
+ * @param {string} options.shadowColor - Shadow color (CSS color string)
23
+ * @param {number} options.shadowBlur - Shadow blur radius
24
+ * @param {number} options.shadowOffsetX - Shadow offset on X axis
25
+ * @param {number} options.shadowOffsetY - Shadow offset on Y axis
26
+ * @param {string} options.HTextAlign - Horizontal text alignment
27
+ * @param {string} options.VTextAlign - Vertical text alignment
28
+ * @param {number} options.pad - General padding
29
+ * @param {number} options.padx - Horizontal padding
30
+ * @param {number} options.pady - Vertical padding
31
+ * @param {number} options.padl - Left padding
32
+ * @param {number} options.padr - Right padding
33
+ * @param {number} options.padt - Top padding
34
+ * @param {number} options.padb - Bottom padding
35
+ * @param {boolean} options.wrap - Whether to wrap text
36
+ * @param {string} options.wrapMode - Wrap mode: "word" or "char"
37
+ * @param {string} options.noWrapMode - No-wrap mode: "ellipsis" or "font-size"
38
+ * @param {string} options.ellipsisMode - Ellipsis mode: "leading", "center", or "trailing"
39
+ * @param {p5.Image|null} options.icon - Icon image to display alongside text (null = no icon)
40
+ * @param {number} options.iconSize - Icon display size in pixels (default 20)
41
+ * @param {string} options.iconPosition - Icon placement: "left", "right", "top", or "bottom"
42
+ * @param {number} options.iconGap - Gap in pixels between icon and text (default 6)
43
+ * @param {p5.Color|null} options.iconTintColor - Optional tint color for the icon
44
+ * @param {number} options.iconOpacity - Icon opacity 0-255 (default 255)
45
+ * @param {number} options.margin - General margin for all sides
46
+ * @param {number} options.marginx - Horizontal margin (left and right)
47
+ * @param {number} options.marginy - Vertical margin (top and bottom)
48
+ * @param {number} options.marginl - Left margin
49
+ * @param {number} options.marginr - Right margin
50
+ * @param {number} options.margint - Top margin
51
+ * @param {number} options.marginb - Bottom margin
52
+ * @param {string} options.type - Component type
53
+ */
54
+ constructor(x, y, width, height, label, {
55
+ id = null,
56
+ parent = null,
57
+ backgroundColor = color('#1e1e2e'),
58
+ textColor = color('#e0e0e0'),
59
+ borderFlag = true,
60
+ borderColor = color('#3a3a4d'),
61
+ borderWidth = 1,
62
+ cornerRadius = 8,
63
+ enableShadow = false,
64
+ shadowColor = 'rgba(0,0,0,0.5)',
65
+ shadowBlur = 12,
66
+ shadowOffsetX = 0,
67
+ shadowOffsetY = 4,
68
+ HTextAlign = 'center',
69
+ VTextAlign = 'center',
70
+ pad = 5,
71
+ padx = null,
72
+ pady = null,
73
+ padl = null,
74
+ padr = null,
75
+ padt = null,
76
+ padb = null,
77
+ wrap = false,
78
+ wrapMode = 'word',
79
+ noWrapMode = 'font-size',
80
+ ellipsisMode = 'trailing',
81
+ icon = null,
82
+ iconSize = 20,
83
+ iconPosition = 'left',
84
+ iconGap = 8,
85
+ iconTintColor = null,
86
+ iconOpacity = 255,
87
+ margin = 0,
88
+ marginx = null,
89
+ marginy = null,
90
+ marginl = null,
91
+ marginr = null,
92
+ margint = null,
93
+ marginb = null,
94
+ type = 'UIComponent',
95
+ minWidth = 0,
96
+ minHeight = 0,
97
+ showDebugOverlay = false,
98
+ } = {}) {
99
+ super(x, y, width, height, backgroundColor, borderFlag, borderColor,
100
+ borderWidth, cornerRadius, enableShadow, shadowColor, shadowBlur,
101
+ shadowOffsetX, shadowOffsetY, { parent: parent, type: type, id: id, margin: margin, marginx: marginx, marginy: marginy, marginl: marginl, marginr: marginr, margint: margint, marginb: marginb, minWidth: minWidth, minHeight: minHeight, showDebugOverlay: showDebugOverlay });
102
+
103
+ this.text = label;
104
+ this.labelSize = 20;
105
+ this.textColor = textColor;
106
+
107
+ this.HTextAlign = HTextAlign;
108
+ this.VTextAlign = VTextAlign;
109
+
110
+ const resolvedPadx = (padx ?? pad ?? 0);
111
+ const resolvedPady = (pady ?? pad ?? 0);
112
+ this.pad = pad;
113
+ this.padx = resolvedPadx;
114
+ this.pady = resolvedPady;
115
+ this.padl = padl ?? resolvedPadx;
116
+ this.padr = padr ?? resolvedPadx;
117
+ this.padt = padt ?? resolvedPady;
118
+ this.padb = padb ?? resolvedPady;
119
+
120
+ this.wrap = wrap;
121
+ this.wrapMode = wrapMode;
122
+ this.noWrapMode = noWrapMode;
123
+ this.ellipsisMode = ellipsisMode;
124
+
125
+ this.icon = icon;
126
+ this.iconSize = iconSize;
127
+ this.iconPosition = iconPosition;
128
+ this.iconGap = iconGap;
129
+ this.iconTintColor = iconTintColor;
130
+ this.iconOpacity = iconOpacity;
131
+
132
+ if (!this.wrap && this.noWrapMode === 'font-size') {
133
+ this.updateLabelSize();
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Converts horizontal alignment string to P5 constant
139
+ * @returns {number} P5 alignment constant
140
+ */
141
+ getHTextAlign() {
142
+ switch (this.HTextAlign) {
143
+ case 'left':
144
+ return LEFT;
145
+ case 'right':
146
+ return RIGHT;
147
+ default:
148
+ return CENTER;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Converts vertical alignment string to P5 constant
154
+ * @returns {number} P5 alignment constant
155
+ */
156
+ getVTextAlign() {
157
+ switch (this.VTextAlign) {
158
+ case 'top':
159
+ return TOP;
160
+ case 'bottom':
161
+ return BOTTOM;
162
+ default:
163
+ return CENTER;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Updates the text and recalculates size
169
+ * @param {string} text - The new text to display
170
+ */
171
+ setText(text) {
172
+ this.text = text;
173
+ if (!this.wrap && this.noWrapMode === 'font-size') {
174
+ this.updateLabelSize();
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Dynamically calculates the optimal text size to fit the container.
180
+ * Strictly fits text within the padded content area.
181
+ * Supports multi-line text (newline characters are respected).
182
+ */
183
+ updateLabelSize() {
184
+ const saved = this._applyIconPadding();
185
+ const maxLabelWidth = this.getContentWidth();
186
+ const maxLabelHeight = this.getContentHeight();
187
+
188
+ if (maxLabelWidth <= 0 || maxLabelHeight <= 0) {
189
+ this.labelSize = 1;
190
+ return;
191
+ }
192
+
193
+ const lines = (this.text ?? '').split('\n');
194
+ const lineCount = Math.max(1, lines.length);
195
+
196
+ let low = 1;
197
+ let high = Math.floor(maxLabelHeight);
198
+ let bestSize = 1;
199
+
200
+ while (low <= high) {
201
+ let mid = Math.floor((low + high) / 2);
202
+ textSize(mid);
203
+ let lineHeight = textAscent() + textDescent();
204
+ let totalHeight = lineHeight * lineCount;
205
+
206
+ let maxLineWidth = 0;
207
+ for (let i = 0; i < lines.length; i++) {
208
+ let w = textWidth(lines[i]);
209
+ if (w > maxLineWidth) maxLineWidth = w;
210
+ }
211
+
212
+ if (maxLineWidth <= maxLabelWidth && totalHeight <= maxLabelHeight) {
213
+ bestSize = mid;
214
+ low = mid + 1;
215
+ } else {
216
+ high = mid - 1;
217
+ }
218
+ }
219
+
220
+ // Final verification: ensure the chosen size truly fits
221
+ textSize(bestSize);
222
+ let lineHeight = textAscent() + textDescent();
223
+ let maxLineWidth = 0;
224
+ for (let i = 0; i < lines.length; i++) {
225
+ let w = textWidth(lines[i]);
226
+ if (w > maxLineWidth) maxLineWidth = w;
227
+ }
228
+ if (maxLineWidth > maxLabelWidth || lineHeight * lineCount > maxLabelHeight) {
229
+ bestSize = max(1, bestSize - 1);
230
+ }
231
+
232
+ this.labelSize = bestSize;
233
+ this._restorePadding(saved);
234
+ }
235
+
236
+ /**
237
+ * Handles width changes and updates text size accordingly
238
+ */
239
+ updateWidth() {
240
+ if (!this.wrap && this.noWrapMode === 'font-size') {
241
+ this.updateLabelSize();
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Handles height changes and updates text size accordingly
247
+ */
248
+ updateHeight() {
249
+ if (!this.wrap && this.noWrapMode === 'font-size') {
250
+ this.updateLabelSize();
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Updates whether text wraps
256
+ * @param {boolean} wrap - True to enable wrapping
257
+ */
258
+ setWrap(wrap) {
259
+ this.wrap = wrap;
260
+ if (!this.wrap && this.noWrapMode === 'font-size') {
261
+ this.updateLabelSize();
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Updates wrap mode
267
+ * @param {string} wrapMode - "word" or "char"
268
+ */
269
+ setWrapMode(wrapMode) {
270
+ this.wrapMode = wrapMode;
271
+ }
272
+
273
+ /**
274
+ * Updates no-wrap mode
275
+ * @param {string} noWrapMode - "ellipsis" or "font-size"
276
+ */
277
+ setNoWrapMode(noWrapMode) {
278
+ this.noWrapMode = noWrapMode;
279
+ if (!this.wrap && this.noWrapMode === 'font-size') {
280
+ this.updateLabelSize();
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Updates ellipsis mode
286
+ * @param {string} ellipsisMode - "leading", "center", or "trailing"
287
+ */
288
+ setEllipsisMode(ellipsisMode) {
289
+ this.ellipsisMode = ellipsisMode;
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Icon methods
294
+ // ---------------------------------------------------------------------------
295
+
296
+ /**
297
+ * Sets the icon image
298
+ * @param {p5.Image|null} img - Icon image (null to remove)
299
+ */
300
+ setIcon(img) {
301
+ this.icon = img;
302
+ if (!this.wrap && this.noWrapMode === 'font-size') {
303
+ this.updateLabelSize();
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Sets the icon display size
309
+ * @param {number} size - Size in pixels
310
+ */
311
+ setIconSize(size) {
312
+ this.iconSize = size;
313
+ if (!this.wrap && this.noWrapMode === 'font-size') {
314
+ this.updateLabelSize();
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Sets the icon position relative to text
320
+ * @param {"left"|"right"|"top"|"bottom"} position
321
+ */
322
+ setIconPosition(position) {
323
+ this.iconPosition = position;
324
+ if (!this.wrap && this.noWrapMode === 'font-size') {
325
+ this.updateLabelSize();
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Sets the gap between icon and text
331
+ * @param {number} gap - Gap in pixels
332
+ */
333
+ setIconGap(gap) {
334
+ this.iconGap = gap;
335
+ if (!this.wrap && this.noWrapMode === 'font-size') {
336
+ this.updateLabelSize();
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Sets the icon tint color
342
+ * @param {p5.Color|null} c
343
+ */
344
+ setIconTintColor(c) {
345
+ this.iconTintColor = c;
346
+ }
347
+
348
+ /**
349
+ * Sets the icon opacity
350
+ * @param {number} o - Opacity 0-255
351
+ */
352
+ setIconOpacity(o) {
353
+ this.iconOpacity = o;
354
+ }
355
+
356
+ /**
357
+ * Computes the effective icon size, growing to fill cross-axis space when
358
+ * the component is larger than the configured iconSize.
359
+ * @private
360
+ * @returns {number} The size (in pixels) to draw the icon at
361
+ */
362
+ _getEffectiveIconSize() {
363
+ if (!this.icon) return this.iconSize;
364
+
365
+ const gap = this.iconGap;
366
+
367
+ if (this.iconPosition === 'left' || this.iconPosition === 'right') {
368
+ // Cross-axis is height; main-axis is width
369
+ const crossSize = Math.max(0, this.height - this.padt - this.padb);
370
+ const mainAxisBudget = Math.max(0, this.width - this.padl - this.padr - gap);
371
+ return Math.max(this.iconSize, Math.min(crossSize, mainAxisBudget));
372
+ } else {
373
+ // Cross-axis is width; main-axis is height
374
+ const crossSize = Math.max(0, this.width - this.padl - this.padr);
375
+ const mainAxisBudget = Math.max(0, this.height - this.padt - this.padb - gap);
376
+ return Math.max(this.iconSize, Math.min(crossSize, mainAxisBudget));
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Temporarily adjusts padding to reserve space for the icon.
382
+ * @private
383
+ * @returns {{padl:number, padr:number, padt:number, padb:number}} saved values
384
+ */
385
+ _applyIconPadding() {
386
+ const saved = { padl: this.padl, padr: this.padr, padt: this.padt, padb: this.padb };
387
+ if (!this.icon) return saved;
388
+
389
+ const hasText = this.text != null && this.text !== '';
390
+ const gap = hasText ? this.iconGap : 0;
391
+ const effectiveSize = this._getEffectiveIconSize();
392
+
393
+ switch (this.iconPosition) {
394
+ case 'right': this.padr += effectiveSize + gap; break;
395
+ case 'top': this.padt += effectiveSize + gap; break;
396
+ case 'bottom': this.padb += effectiveSize + gap; break;
397
+ case 'left':
398
+ default: this.padl += effectiveSize + gap; break;
399
+ }
400
+
401
+ return saved;
402
+ }
403
+
404
+ /**
405
+ * Restores padding values saved by _applyIconPadding
406
+ * @private
407
+ * @param {{padl:number, padr:number, padt:number, padb:number}} saved
408
+ */
409
+ _restorePadding(saved) {
410
+ this.padl = saved.padl;
411
+ this.padr = saved.padr;
412
+ this.padt = saved.padt;
413
+ this.padb = saved.padb;
414
+ }
415
+
416
+ /**
417
+ * Draws the icon centered in the full content area (icon-only mode)
418
+ * @private
419
+ */
420
+ _drawIconCentered() {
421
+ const cw = this.getContentWidth();
422
+ const ch = this.getContentHeight();
423
+ const s = Math.min(cw, ch);
424
+ const ix = this.padl + (cw - s) / 2;
425
+ const iy = this.padt + (ch - s) / 2;
426
+ this._drawIconAt(ix, iy, s, s);
427
+ }
428
+
429
+ /**
430
+ * Draws the icon at its designated position beside the text
431
+ * @private
432
+ */
433
+ _drawIconPositioned() {
434
+ const contentW = this.getContentWidth();
435
+ const contentH = this.getContentHeight();
436
+ const s = this._getEffectiveIconSize();
437
+ let ix, iy;
438
+
439
+ switch (this.iconPosition) {
440
+ case 'right':
441
+ ix = this.width - this.padr - s;
442
+ iy = this.padt + (contentH - s) / 2;
443
+ break;
444
+ case 'top':
445
+ ix = this.padl + (contentW - s) / 2;
446
+ iy = this.padt;
447
+ break;
448
+ case 'bottom':
449
+ ix = this.padl + (contentW - s) / 2;
450
+ iy = this.height - this.padb - s;
451
+ break;
452
+ case 'left':
453
+ default:
454
+ ix = this.padl;
455
+ iy = this.padt + (contentH - s) / 2;
456
+ break;
457
+ }
458
+
459
+ this._drawIconAt(ix, iy, s, s);
460
+ }
461
+
462
+ /**
463
+ * Renders the icon image at the specified local coordinates
464
+ * @private
465
+ * @param {number} x - Local x position
466
+ * @param {number} y - Local y position
467
+ * @param {number} w - Draw width
468
+ * @param {number} h - Draw height
469
+ */
470
+ _drawIconAt(x, y, w, h) {
471
+ push();
472
+ if (this.iconTintColor) {
473
+ tint(this.iconTintColor, this.iconOpacity);
474
+ } else if (this.iconOpacity < 255) {
475
+ tint(255, this.iconOpacity);
476
+ }
477
+ imageMode(CORNER);
478
+ image(this.icon, x, y, w, h);
479
+ noTint();
480
+ pop();
481
+ }
482
+
483
+ /**
484
+ * Applies text alignment and returns the final position
485
+ * @returns {{x: number, y: number}}
486
+ */
487
+ getTextPosition() {
488
+ textSize(this.labelSize);
489
+ const actualHeight = textAscent() + textDescent();
490
+
491
+ let x;
492
+ if (this.HTextAlign === 'left') {
493
+ textAlign(LEFT, CENTER);
494
+ x = this.padl;
495
+ } else if (this.HTextAlign === 'right') {
496
+ textAlign(RIGHT, CENTER);
497
+ x = this.width - this.padr;
498
+ } else {
499
+ textAlign(CENTER, CENTER);
500
+ x = this.padl + this.getContentWidth() / 2;
501
+ }
502
+
503
+ let y;
504
+ if (this.VTextAlign === 'top') {
505
+ textAlign(this.getHTextAlign(), TOP);
506
+ y = this.padt;
507
+ } else if (this.VTextAlign === 'bottom') {
508
+ textAlign(this.getHTextAlign(), BOTTOM);
509
+ y = this.height - this.padb;
510
+ } else {
511
+ textAlign(this.getHTextAlign(), CENTER);
512
+ y = this.padt + this.getContentHeight() / 2;
513
+ }
514
+
515
+ return { x, y };
516
+ }
517
+
518
+ /**
519
+ * Draws text based on wrapping and overflow settings
520
+ */
521
+ renderText() {
522
+ const hasIcon = this.icon != null;
523
+ const hasText = this.text != null && this.text !== '';
524
+
525
+ // Icon-only mode: center the icon in the content area
526
+ if (hasIcon && !hasText) {
527
+ this._drawIconCentered();
528
+ return;
529
+ }
530
+
531
+ // Apply icon padding adjustment for text rendering
532
+ const saved = hasIcon ? this._applyIconPadding() : null;
533
+
534
+ textSize(this.labelSize);
535
+
536
+ if (this.wrap) {
537
+ const lines = this.getWrappedLines();
538
+ this.renderWrappedLines(lines);
539
+ } else if (this.noWrapMode === 'font-size') {
540
+ const lines = (this.text ?? '').split('\n');
541
+ if (lines.length > 1) {
542
+ this.renderNoWrapFontSizeLines(lines);
543
+ } else {
544
+ const line = this.getNoWrapLine();
545
+ const { x, y } = this.getTextPosition();
546
+ text(line, x, y);
547
+ }
548
+ } else {
549
+ const line = this.getNoWrapLine();
550
+ const { x, y } = this.getTextPosition();
551
+ text(line, x, y);
552
+ }
553
+
554
+ // Restore padding and render icon
555
+ if (saved) {
556
+ this._restorePadding(saved);
557
+ this._drawIconPositioned();
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Renders multiple lines for no-wrap font-size mode
563
+ * @param {string[]} lines - The lines to render
564
+ */
565
+ renderNoWrapFontSizeLines(lines) {
566
+ const lineHeight = textAscent() + textDescent();
567
+ const contentHeight = this.getContentHeight();
568
+ const baseX = this.getAlignedTextX();
569
+
570
+ let startY;
571
+ if (this.VTextAlign === 'top') {
572
+ startY = this.padt + textAscent();
573
+ } else if (this.VTextAlign === 'bottom') {
574
+ const renderedHeight = lineHeight * lines.length;
575
+ startY = this.height - this.padb - renderedHeight + textAscent();
576
+ } else {
577
+ const renderedHeight = lineHeight * lines.length;
578
+ startY = this.padt + (contentHeight - renderedHeight) / 2 + textAscent();
579
+ }
580
+
581
+ for (let i = 0; i < lines.length; i++) {
582
+ const y = startY + i * lineHeight;
583
+ text(lines[i], baseX, y);
584
+ }
585
+ }
586
+
587
+ getContentWidth() {
588
+ return max(0, this.width - this.padl - this.padr);
589
+ }
590
+
591
+ getContentHeight() {
592
+ return max(0, this.height - this.padt - this.padb);
593
+ }
594
+
595
+ getNoWrapLine() {
596
+ const flatText = (this.text ?? '').replace(/\s*\n\s*/g, ' ');
597
+ if (this.noWrapMode !== 'ellipsis') {
598
+ return flatText;
599
+ }
600
+
601
+ const maxWidth = this.getContentWidth();
602
+ if (textWidth(flatText) <= maxWidth) {
603
+ return flatText;
604
+ }
605
+
606
+ const ellipsis = '...';
607
+ if (textWidth(ellipsis) >= maxWidth) {
608
+ return ellipsis;
609
+ }
610
+
611
+ if (this.ellipsisMode === 'leading') {
612
+ return this.getLeadingEllipsis(flatText, maxWidth, ellipsis);
613
+ }
614
+
615
+ if (this.ellipsisMode === 'center') {
616
+ return this.getCenterEllipsis(flatText, maxWidth, ellipsis);
617
+ }
618
+
619
+ return this.getTrailingEllipsis(flatText, maxWidth, ellipsis);
620
+ }
621
+
622
+ getTrailingEllipsis(flatText, maxWidth, ellipsis) {
623
+ let low = 0;
624
+ let high = flatText.length;
625
+ let best = '';
626
+
627
+ while (low <= high) {
628
+ const mid = Math.floor((low + high) / 2);
629
+ const candidate = flatText.slice(0, mid) + ellipsis;
630
+ if (textWidth(candidate) <= maxWidth) {
631
+ best = candidate;
632
+ low = mid + 1;
633
+ } else {
634
+ high = mid - 1;
635
+ }
636
+ }
637
+
638
+ return best;
639
+ }
640
+
641
+ getLeadingEllipsis(flatText, maxWidth, ellipsis) {
642
+ let low = 0;
643
+ let high = flatText.length;
644
+ let best = '';
645
+
646
+ while (low <= high) {
647
+ const mid = Math.floor((low + high) / 2);
648
+ const candidate = ellipsis + flatText.slice(flatText.length - mid);
649
+ if (textWidth(candidate) <= maxWidth) {
650
+ best = candidate;
651
+ low = mid + 1;
652
+ } else {
653
+ high = mid - 1;
654
+ }
655
+ }
656
+
657
+ return best;
658
+ }
659
+
660
+ getCenterEllipsis(flatText, maxWidth, ellipsis) {
661
+ let low = 0;
662
+ let high = flatText.length;
663
+ let best = '';
664
+
665
+ while (low <= high) {
666
+ const mid = Math.floor((low + high) / 2);
667
+ const headCount = Math.ceil(mid / 2);
668
+ const tailCount = Math.floor(mid / 2);
669
+ const candidate =
670
+ flatText.slice(0, headCount) + ellipsis + flatText.slice(flatText.length - tailCount);
671
+
672
+ if (textWidth(candidate) <= maxWidth) {
673
+ best = candidate;
674
+ low = mid + 1;
675
+ } else {
676
+ high = mid - 1;
677
+ }
678
+ }
679
+
680
+ return best;
681
+ }
682
+
683
+ getWrappedLines() {
684
+ const maxWidth = this.getContentWidth();
685
+ const textValue = this.text ?? '';
686
+ const paragraphs = textValue.split('\n');
687
+ const lines = [];
688
+
689
+ for (let i = 0; i < paragraphs.length; i += 1) {
690
+ const paragraph = paragraphs[i];
691
+ if (paragraph.length === 0) {
692
+ lines.push('');
693
+ continue;
694
+ }
695
+
696
+ if (this.wrapMode === 'char') {
697
+ lines.push(...this.wrapByChar(paragraph, maxWidth));
698
+ } else {
699
+ lines.push(...this.wrapByWord(paragraph, maxWidth));
700
+ }
701
+ }
702
+
703
+ return lines;
704
+ }
705
+
706
+ wrapByWord(textValue, maxWidth) {
707
+ const words = textValue.trim().length ? textValue.split(/\s+/) : [''];
708
+ const lines = [];
709
+ let currentLine = '';
710
+
711
+ for (let i = 0; i < words.length; i += 1) {
712
+ const word = words[i];
713
+ if (!currentLine) {
714
+ if (textWidth(word) <= maxWidth) {
715
+ currentLine = word;
716
+ } else {
717
+ lines.push(...this.wrapByChar(word, maxWidth));
718
+ currentLine = '';
719
+ }
720
+ continue;
721
+ }
722
+
723
+ const testLine = currentLine + ' ' + word;
724
+ if (textWidth(testLine) <= maxWidth) {
725
+ currentLine = testLine;
726
+ } else {
727
+ lines.push(currentLine);
728
+ if (textWidth(word) <= maxWidth) {
729
+ currentLine = word;
730
+ } else {
731
+ lines.push(...this.wrapByChar(word, maxWidth));
732
+ currentLine = '';
733
+ }
734
+ }
735
+ }
736
+
737
+ if (currentLine) {
738
+ lines.push(currentLine);
739
+ }
740
+
741
+ return lines;
742
+ }
743
+
744
+ wrapByChar(textValue, maxWidth) {
745
+ const lines = [];
746
+ let currentLine = '';
747
+
748
+ for (let i = 0; i < textValue.length; i += 1) {
749
+ const nextLine = currentLine + textValue[i];
750
+ if (textWidth(nextLine) <= maxWidth || currentLine.length === 0) {
751
+ currentLine = nextLine;
752
+ } else {
753
+ lines.push(currentLine);
754
+ currentLine = textValue[i];
755
+ }
756
+ }
757
+
758
+ if (currentLine) {
759
+ lines.push(currentLine);
760
+ }
761
+
762
+ return lines;
763
+ }
764
+
765
+ renderWrappedLines(lines) {
766
+ const lineHeight = textAscent() + textDescent();
767
+ const totalHeight = lineHeight * lines.length;
768
+ const contentHeight = this.getContentHeight();
769
+ const baseX = this.getAlignedTextX();
770
+ const maxLines = Math.max(1, Math.floor(contentHeight / Math.max(1, lineHeight)));
771
+ let visibleLines = lines;
772
+
773
+ if (lines.length > maxLines) {
774
+ visibleLines = lines.slice(0, maxLines);
775
+ const lastIndex = visibleLines.length - 1;
776
+ visibleLines[lastIndex] = this.fitLineWithEllipsis(visibleLines[lastIndex], true);
777
+ }
778
+
779
+ let startY;
780
+ if (this.VTextAlign === 'top') {
781
+ startY = this.padt + textAscent();
782
+ } else if (this.VTextAlign === 'bottom') {
783
+ const renderedHeight = lineHeight * visibleLines.length;
784
+ startY = this.height - this.padb - renderedHeight + textAscent();
785
+ } else {
786
+ const renderedHeight = lineHeight * visibleLines.length;
787
+ startY = this.padt + (contentHeight - renderedHeight) / 2 + textAscent();
788
+ }
789
+
790
+ for (let i = 0; i < visibleLines.length; i += 1) {
791
+ const y = startY + i * lineHeight;
792
+ text(visibleLines[i], baseX, y);
793
+ }
794
+ }
795
+
796
+ fitLineWithEllipsis(lineText, forceEllipsis = false) {
797
+ const maxWidth = this.getContentWidth();
798
+ const ellipsis = '...';
799
+ if (!forceEllipsis && textWidth(lineText) <= maxWidth) {
800
+ return lineText;
801
+ }
802
+
803
+ if (textWidth(ellipsis) >= maxWidth) {
804
+ return ellipsis;
805
+ }
806
+
807
+ let low = 0;
808
+ let high = lineText.length;
809
+ let best = ellipsis;
810
+
811
+ while (low <= high) {
812
+ const mid = Math.floor((low + high) / 2);
813
+ const candidate = lineText.slice(0, mid) + ellipsis;
814
+ if (textWidth(candidate) <= maxWidth) {
815
+ best = candidate;
816
+ low = mid + 1;
817
+ } else {
818
+ high = mid - 1;
819
+ }
820
+ }
821
+
822
+ return best;
823
+ }
824
+
825
+ getAlignedTextX() {
826
+ if (this.HTextAlign === 'left') {
827
+ textAlign(LEFT, BASELINE);
828
+ return this.padl;
829
+ }
830
+ if (this.HTextAlign === 'right') {
831
+ textAlign(RIGHT, BASELINE);
832
+ return this.width - this.padr;
833
+ }
834
+
835
+ textAlign(CENTER, BASELINE);
836
+ return this.padl + this.getContentWidth() / 2;
837
+ }
838
+ }