@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.
- package/Core/Component.js +207 -1
- package/Core/GUIEvent/KeyboardEvent.js +2 -1
- package/Core/Root.js +40 -30
- package/Frames/DummyFrame.js +14 -12
- package/Frames/Frame.js +117 -57
- package/Frames/FrameComponent.js +9 -2
- package/Frames/GridFrame.js +131 -27
- package/Frames/ScrollFrame.js +154 -40
- package/README.md +8 -8
- package/UIComponents/Button.js +281 -0
- package/UIComponents/Icon.js +374 -0
- package/UIComponents/Input.js +15 -8
- package/UIComponents/Label.js +102 -158
- package/UIComponents/TextComponent.js +838 -0
- package/UIComponents/TextField.js +170 -39
- package/UIComponents/UIComponent.js +68 -35
- package/index.js +5 -1
- package/package.json +9 -2
- package/problems.txt +1 -0
|
@@ -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
|
+
}
|