@teammates/consolonia 0.2.0 → 0.3.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/dist/drawing/context.d.ts +4 -0
- package/dist/drawing/context.js +13 -0
- package/dist/widgets/chat-view.d.ts +15 -0
- package/dist/widgets/chat-view.js +155 -18
- package/dist/widgets/markdown.js +108 -17
- package/package.json +1 -1
|
@@ -74,4 +74,8 @@ export declare class DrawingContext {
|
|
|
74
74
|
setPixel(x: number, y: number, pixel: Pixel): void;
|
|
75
75
|
/** Blend a pixel at (x, y) with what's already there. Respects clip. */
|
|
76
76
|
blendPixel(x: number, y: number, pixel: Pixel): void;
|
|
77
|
+
/** Replace the background color of an existing cell. Respects clip. */
|
|
78
|
+
highlightCell(x: number, y: number, bgColor: Color): void;
|
|
79
|
+
/** Read the character at absolute buffer coordinates (ignores translate). */
|
|
80
|
+
readCharAbsolute(x: number, y: number): string;
|
|
77
81
|
}
|
package/dist/drawing/context.js
CHANGED
|
@@ -272,4 +272,17 @@ export class DrawingContext {
|
|
|
272
272
|
return;
|
|
273
273
|
this.bufSet(x, y, blendPixel(pixel, this.bufGet(x, y)));
|
|
274
274
|
}
|
|
275
|
+
// ── Selection helpers ─────────────────────────────────────────
|
|
276
|
+
/** Replace the background color of an existing cell. Respects clip. */
|
|
277
|
+
highlightCell(x, y, bgColor) {
|
|
278
|
+
if (!this.isVisible(x, y))
|
|
279
|
+
return;
|
|
280
|
+
const existing = this.bufGet(x, y);
|
|
281
|
+
const newBg = background(bgColor);
|
|
282
|
+
this.bufSet(x, y, { foreground: existing.foreground, background: newBg });
|
|
283
|
+
}
|
|
284
|
+
/** Read the character at absolute buffer coordinates (ignores translate). */
|
|
285
|
+
readCharAbsolute(x, y) {
|
|
286
|
+
return this.buffer.get(x, y).foreground.symbol.text;
|
|
287
|
+
}
|
|
275
288
|
}
|
|
@@ -142,6 +142,11 @@ export declare class ChatView extends Control {
|
|
|
142
142
|
private _dragging;
|
|
143
143
|
/** The Y offset within the thumb where the drag started. */
|
|
144
144
|
private _dragOffsetY;
|
|
145
|
+
private _selAnchor;
|
|
146
|
+
private _selEnd;
|
|
147
|
+
private _selecting;
|
|
148
|
+
/** DrawingContext reference from the last render (for text extraction). */
|
|
149
|
+
private _ctx;
|
|
145
150
|
/** Optional widget that replaces the input area (e.g. Interview). */
|
|
146
151
|
private _inputOverride;
|
|
147
152
|
constructor(options?: ChatViewOptions);
|
|
@@ -235,5 +240,15 @@ export declare class ChatView extends Control {
|
|
|
235
240
|
render(ctx: DrawingContext): void;
|
|
236
241
|
private _renderFeed;
|
|
237
242
|
private _renderDropdown;
|
|
243
|
+
/** Whether a non-zero text selection is active. */
|
|
244
|
+
private _hasSelection;
|
|
245
|
+
/** Copy the selected text and clear the selection. */
|
|
246
|
+
private _copySelection;
|
|
247
|
+
/** Extract the plain text within the current selection from the pixel buffer. */
|
|
248
|
+
private _getSelectedText;
|
|
249
|
+
/** Render the selection highlight overlay within the feed area. */
|
|
250
|
+
private _renderSelection;
|
|
251
|
+
/** Clear any active text selection. */
|
|
252
|
+
clearSelection(): void;
|
|
238
253
|
private _autoScrollToBottom;
|
|
239
254
|
}
|
|
@@ -33,8 +33,11 @@ import { Control } from "../layout/control.js";
|
|
|
33
33
|
import { StyledText } from "./styled-text.js";
|
|
34
34
|
import { Text } from "./text.js";
|
|
35
35
|
import { TextInput, } from "./text-input.js";
|
|
36
|
-
// ── URL detection
|
|
36
|
+
// ── URL / file path detection ───────────────────────────────────────
|
|
37
37
|
const URL_REGEX = /https?:\/\/[^\s)>\]]+/g;
|
|
38
|
+
const FILE_PATH_REGEX = /(?:[A-Za-z]:[/\\]|\/)[^\s:*?"<>|)>\]]*[^\s:*?"<>|)>\].,:;!]/g;
|
|
39
|
+
// ── Selection highlight color ─────────────────────────────────────
|
|
40
|
+
const SELECTION_BG = { r: 60, g: 100, b: 180, a: 255 };
|
|
38
41
|
// ── ChatView ───────────────────────────────────────────────────────
|
|
39
42
|
export class ChatView extends Control {
|
|
40
43
|
// ── Child controls ─────────────────────────────────────────────
|
|
@@ -82,6 +85,12 @@ export class ChatView extends Control {
|
|
|
82
85
|
_dragging = false;
|
|
83
86
|
/** The Y offset within the thumb where the drag started. */
|
|
84
87
|
_dragOffsetY = 0;
|
|
88
|
+
// ── Selection state ──────────────────────────────────────────
|
|
89
|
+
_selAnchor = null;
|
|
90
|
+
_selEnd = null;
|
|
91
|
+
_selecting = false;
|
|
92
|
+
/** DrawingContext reference from the last render (for text extraction). */
|
|
93
|
+
_ctx = null;
|
|
85
94
|
/** Optional widget that replaces the input area (e.g. Interview). */
|
|
86
95
|
_inputOverride = null;
|
|
87
96
|
constructor(options = {}) {
|
|
@@ -452,11 +461,24 @@ export class ChatView extends Control {
|
|
|
452
461
|
handleInput(event) {
|
|
453
462
|
if (event.type === "key") {
|
|
454
463
|
const ke = event.event;
|
|
455
|
-
// Ctrl+C
|
|
464
|
+
// Ctrl+C: if selection active, copy; otherwise emit for the app
|
|
456
465
|
if (ke.key === "c" && ke.ctrl && !ke.alt && !ke.shift) {
|
|
466
|
+
if (this._hasSelection()) {
|
|
467
|
+
this._copySelection();
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
457
470
|
this.emit("ctrlc");
|
|
458
471
|
return true;
|
|
459
472
|
}
|
|
473
|
+
// Enter: if selection active, copy; otherwise fall through
|
|
474
|
+
if (ke.key === "enter" && this._hasSelection()) {
|
|
475
|
+
this._copySelection();
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
// Any key clears active selection
|
|
479
|
+
if (this._hasSelection()) {
|
|
480
|
+
this.clearSelection();
|
|
481
|
+
}
|
|
460
482
|
// Dropdown navigation
|
|
461
483
|
if (this._dropdownItems.length > 0) {
|
|
462
484
|
if (ke.key === "up")
|
|
@@ -492,7 +514,7 @@ export class ChatView extends Control {
|
|
|
492
514
|
return true;
|
|
493
515
|
}
|
|
494
516
|
}
|
|
495
|
-
// Mouse events: wheel scrolling
|
|
517
|
+
// Mouse events: wheel scrolling, scrollbar drag, selection, actions
|
|
496
518
|
if (event.type === "mouse") {
|
|
497
519
|
const me = event.event;
|
|
498
520
|
if (me.type === "wheelup") {
|
|
@@ -503,21 +525,21 @@ export class ChatView extends Control {
|
|
|
503
525
|
this.scrollFeed(3);
|
|
504
526
|
return true;
|
|
505
527
|
}
|
|
528
|
+
// Precompute scrollbar hit for reuse
|
|
529
|
+
const onScrollbar = this._scrollbarVisible &&
|
|
530
|
+
me.x === this._scrollbarX &&
|
|
531
|
+
me.y >= this._feedY &&
|
|
532
|
+
me.y < this._feedY + this._feedH;
|
|
506
533
|
// Scrollbar drag
|
|
507
534
|
if (this._scrollbarVisible) {
|
|
508
|
-
const onScrollbar = me.x === this._scrollbarX &&
|
|
509
|
-
me.y >= this._feedY &&
|
|
510
|
-
me.y < this._feedY + this._feedH;
|
|
511
535
|
if (me.type === "press" && me.button === "left" && onScrollbar) {
|
|
512
536
|
const relY = me.y - this._feedY;
|
|
513
537
|
if (relY >= this._thumbPos &&
|
|
514
538
|
relY < this._thumbPos + this._thumbSize) {
|
|
515
|
-
// Clicked on thumb — start dragging
|
|
516
539
|
this._dragging = true;
|
|
517
540
|
this._dragOffsetY = relY - this._thumbPos;
|
|
518
541
|
}
|
|
519
542
|
else {
|
|
520
|
-
// Clicked on track — jump to that position
|
|
521
543
|
const ratio = relY / this._feedH;
|
|
522
544
|
this._feedScrollOffset = Math.round(ratio * this._maxScroll);
|
|
523
545
|
this._feedScrollOffset = Math.max(0, Math.min(this._feedScrollOffset, this._maxScroll));
|
|
@@ -540,28 +562,70 @@ export class ChatView extends Control {
|
|
|
540
562
|
return true;
|
|
541
563
|
}
|
|
542
564
|
}
|
|
543
|
-
// Ctrl+click to open URLs
|
|
565
|
+
// Ctrl+click to open URLs or file paths
|
|
544
566
|
if (me.type === "press" && me.button === "left" && me.ctrl) {
|
|
545
567
|
const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
|
|
546
568
|
if (feedLineIdx >= 0) {
|
|
547
569
|
const text = this._extractFeedLineText(feedLineIdx);
|
|
570
|
+
// Collect all clickable targets: URLs and absolute file paths
|
|
548
571
|
URL_REGEX.lastIndex = 0;
|
|
572
|
+
FILE_PATH_REGEX.lastIndex = 0;
|
|
549
573
|
const urls = [...text.matchAll(URL_REGEX)];
|
|
550
|
-
|
|
551
|
-
|
|
574
|
+
const paths = [...text.matchAll(FILE_PATH_REGEX)];
|
|
575
|
+
const allTargets = [
|
|
576
|
+
...urls.map((m) => ({ index: m.index, text: m[0], type: "link" })),
|
|
577
|
+
...paths.map((m) => ({ index: m.index, text: m[0], type: "file" })),
|
|
578
|
+
].sort((a, b) => a.index - b.index);
|
|
579
|
+
if (allTargets.length === 1) {
|
|
580
|
+
this.emit(allTargets[0].type, allTargets[0].text);
|
|
552
581
|
return true;
|
|
553
582
|
}
|
|
554
|
-
if (
|
|
555
|
-
// Try to resolve which URL based on click position
|
|
583
|
+
if (allTargets.length > 1) {
|
|
556
584
|
const row = this._screenToFeedRow.get(me.y) ?? 0;
|
|
557
585
|
const col = me.x - this._feedX;
|
|
558
586
|
const charOffset = row * this._contentWidth + col;
|
|
559
|
-
const hit =
|
|
560
|
-
|
|
587
|
+
const hit = allTargets.find((t) => charOffset >= t.index && charOffset < t.index + t.text.length);
|
|
588
|
+
const target = hit ?? allTargets[0];
|
|
589
|
+
this.emit(target.type, target.text);
|
|
561
590
|
return true;
|
|
562
591
|
}
|
|
563
592
|
}
|
|
564
593
|
}
|
|
594
|
+
// Text selection: start on left press in feed area
|
|
595
|
+
if (me.type === "press" &&
|
|
596
|
+
me.button === "left" &&
|
|
597
|
+
!me.ctrl &&
|
|
598
|
+
!onScrollbar) {
|
|
599
|
+
const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
|
|
600
|
+
const isAction = feedLineIdx >= 0 && this._feedActions.has(feedLineIdx);
|
|
601
|
+
if (!isAction) {
|
|
602
|
+
this._selAnchor = { x: me.x, y: me.y };
|
|
603
|
+
this._selEnd = { x: me.x, y: me.y };
|
|
604
|
+
this._selecting = true;
|
|
605
|
+
this.invalidate();
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// Text selection: extend on move
|
|
610
|
+
if (me.type === "move" && this._selecting) {
|
|
611
|
+
this._selEnd = { x: me.x, y: me.y };
|
|
612
|
+
this.invalidate();
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
// Text selection: finalize on release
|
|
616
|
+
if (me.type === "release" && this._selecting) {
|
|
617
|
+
this._selecting = false;
|
|
618
|
+
// If anchor == end (just a click, no drag), clear selection
|
|
619
|
+
if (this._selAnchor &&
|
|
620
|
+
this._selEnd &&
|
|
621
|
+
this._selAnchor.x === this._selEnd.x &&
|
|
622
|
+
this._selAnchor.y === this._selEnd.y) {
|
|
623
|
+
this._selAnchor = null;
|
|
624
|
+
this._selEnd = null;
|
|
625
|
+
}
|
|
626
|
+
this.invalidate();
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
565
629
|
// Action hover/click in feed area
|
|
566
630
|
if (this._feedActions.size > 0) {
|
|
567
631
|
const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
|
|
@@ -570,14 +634,12 @@ export class ChatView extends Control {
|
|
|
570
634
|
const newHover = entry ? feedLineIdx : -1;
|
|
571
635
|
if (newHover !== this._hoveredAction ||
|
|
572
636
|
(entry && entry.items.length > 1)) {
|
|
573
|
-
// Restore previous hover
|
|
574
637
|
if (this._hoveredAction >= 0) {
|
|
575
638
|
const prev = this._feedActions.get(this._hoveredAction);
|
|
576
639
|
if (prev) {
|
|
577
640
|
this._feedLines[this._hoveredAction].lines = [prev.normalStyle];
|
|
578
641
|
}
|
|
579
642
|
}
|
|
580
|
-
// Apply new hover — highlight only the hovered action item
|
|
581
643
|
if (entry && newHover >= 0) {
|
|
582
644
|
const hitItem = this._resolveActionItem(entry, me.x);
|
|
583
645
|
const hoverLine = this._buildHoverLine(entry, hitItem);
|
|
@@ -586,7 +648,6 @@ export class ChatView extends Control {
|
|
|
586
648
|
this._hoveredAction = newHover;
|
|
587
649
|
this.invalidate();
|
|
588
650
|
}
|
|
589
|
-
// Don't consume — let other handlers run too
|
|
590
651
|
}
|
|
591
652
|
if (me.type === "press" && me.button === "left" && entry) {
|
|
592
653
|
const hitItem = this._resolveActionItem(entry, me.x);
|
|
@@ -681,6 +742,7 @@ export class ChatView extends Control {
|
|
|
681
742
|
}
|
|
682
743
|
// ── Render ─────────────────────────────────────────────────────
|
|
683
744
|
render(ctx) {
|
|
745
|
+
this._ctx = ctx;
|
|
684
746
|
const b = this.bounds;
|
|
685
747
|
if (!b || b.width < 1 || b.height < 3)
|
|
686
748
|
return;
|
|
@@ -930,6 +992,10 @@ export class ChatView extends Control {
|
|
|
930
992
|
else {
|
|
931
993
|
this._scrollbarVisible = false;
|
|
932
994
|
}
|
|
995
|
+
// Render selection highlight overlay
|
|
996
|
+
if (this._selAnchor && this._selEnd) {
|
|
997
|
+
this._renderSelection(ctx, x, y, width, height);
|
|
998
|
+
}
|
|
933
999
|
ctx.popClip();
|
|
934
1000
|
}
|
|
935
1001
|
// ── Dropdown rendering ─────────────────────────────────────────
|
|
@@ -947,6 +1013,77 @@ export class ChatView extends Control {
|
|
|
947
1013
|
ctx.drawText(x, y + i, truncated, style);
|
|
948
1014
|
}
|
|
949
1015
|
}
|
|
1016
|
+
// ── Selection ──────────────────────────────────────────────────
|
|
1017
|
+
/** Whether a non-zero text selection is active. */
|
|
1018
|
+
_hasSelection() {
|
|
1019
|
+
return (this._selAnchor !== null &&
|
|
1020
|
+
this._selEnd !== null &&
|
|
1021
|
+
(this._selAnchor.x !== this._selEnd.x ||
|
|
1022
|
+
this._selAnchor.y !== this._selEnd.y));
|
|
1023
|
+
}
|
|
1024
|
+
/** Copy the selected text and clear the selection. */
|
|
1025
|
+
_copySelection() {
|
|
1026
|
+
const text = this._getSelectedText();
|
|
1027
|
+
if (text) {
|
|
1028
|
+
this.emit("copy", text);
|
|
1029
|
+
}
|
|
1030
|
+
this.clearSelection();
|
|
1031
|
+
}
|
|
1032
|
+
/** Extract the plain text within the current selection from the pixel buffer. */
|
|
1033
|
+
_getSelectedText() {
|
|
1034
|
+
if (!this._selAnchor || !this._selEnd || !this._ctx)
|
|
1035
|
+
return "";
|
|
1036
|
+
let startY = this._selAnchor.y;
|
|
1037
|
+
let startX = this._selAnchor.x;
|
|
1038
|
+
let endY = this._selEnd.y;
|
|
1039
|
+
let endX = this._selEnd.x;
|
|
1040
|
+
if (startY > endY || (startY === endY && startX > endX)) {
|
|
1041
|
+
[startY, endY] = [endY, startY];
|
|
1042
|
+
[startX, endX] = [endX, startX];
|
|
1043
|
+
}
|
|
1044
|
+
const lines = [];
|
|
1045
|
+
for (let row = startY; row <= endY; row++) {
|
|
1046
|
+
const colStart = row === startY ? startX : this._feedX;
|
|
1047
|
+
const colEnd = row === endY ? endX : this._feedX + this._contentWidth - 1;
|
|
1048
|
+
let line = "";
|
|
1049
|
+
for (let col = colStart; col <= colEnd; col++) {
|
|
1050
|
+
const ch = this._ctx.readCharAbsolute(col, row);
|
|
1051
|
+
line += ch || " ";
|
|
1052
|
+
}
|
|
1053
|
+
lines.push(line.trimEnd());
|
|
1054
|
+
}
|
|
1055
|
+
return lines.join("\n");
|
|
1056
|
+
}
|
|
1057
|
+
/** Render the selection highlight overlay within the feed area. */
|
|
1058
|
+
_renderSelection(ctx, feedX, feedY, feedW, feedH) {
|
|
1059
|
+
if (!this._selAnchor || !this._selEnd)
|
|
1060
|
+
return;
|
|
1061
|
+
let startY = this._selAnchor.y;
|
|
1062
|
+
let startX = this._selAnchor.x;
|
|
1063
|
+
let endY = this._selEnd.y;
|
|
1064
|
+
let endX = this._selEnd.x;
|
|
1065
|
+
if (startY > endY || (startY === endY && startX > endX)) {
|
|
1066
|
+
[startY, endY] = [endY, startY];
|
|
1067
|
+
[startX, endX] = [endX, startX];
|
|
1068
|
+
}
|
|
1069
|
+
// Clamp to feed area
|
|
1070
|
+
startY = Math.max(startY, feedY);
|
|
1071
|
+
endY = Math.min(endY, feedY + feedH - 1);
|
|
1072
|
+
for (let row = startY; row <= endY; row++) {
|
|
1073
|
+
const colStart = row === startY ? startX : feedX;
|
|
1074
|
+
const colEnd = row === endY ? endX : feedX + feedW - 2;
|
|
1075
|
+
for (let col = colStart; col <= colEnd; col++) {
|
|
1076
|
+
ctx.highlightCell(col, row, SELECTION_BG);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
/** Clear any active text selection. */
|
|
1081
|
+
clearSelection() {
|
|
1082
|
+
this._selAnchor = null;
|
|
1083
|
+
this._selEnd = null;
|
|
1084
|
+
this._selecting = false;
|
|
1085
|
+
this.invalidate();
|
|
1086
|
+
}
|
|
950
1087
|
// ── Auto-scroll ────────────────────────────────────────────────
|
|
951
1088
|
_autoScrollToBottom() {
|
|
952
1089
|
// Set scroll to a very large value; it will be clamped during render
|
package/dist/widgets/markdown.js
CHANGED
|
@@ -297,9 +297,10 @@ function renderList(token, lines, theme, synTheme, width, indent, ctx) {
|
|
|
297
297
|
lines.push([{ text: indent, style: theme.text }]);
|
|
298
298
|
}
|
|
299
299
|
// ── Tables ───────────────────────────────────────────────────────
|
|
300
|
-
function renderTable(token, lines, theme,
|
|
300
|
+
function renderTable(token, lines, theme, width, indent) {
|
|
301
301
|
const numCols = token.header.length;
|
|
302
|
-
|
|
302
|
+
const avail = width - indent.length;
|
|
303
|
+
// Compute natural column widths from content
|
|
303
304
|
const colWidths = token.header.map((h) => plainText(h.tokens).length);
|
|
304
305
|
for (const row of token.rows) {
|
|
305
306
|
for (let c = 0; c < numCols; c++) {
|
|
@@ -312,42 +313,132 @@ function renderTable(token, lines, theme, _width, indent) {
|
|
|
312
313
|
for (let c = 0; c < numCols; c++) {
|
|
313
314
|
colWidths[c] = Math.max(3, colWidths[c]) + 2;
|
|
314
315
|
}
|
|
316
|
+
// Shrink columns if total table width exceeds available width
|
|
317
|
+
// Total = sum(colWidths) + numCols + 1 (for │ borders)
|
|
318
|
+
const MIN_COL = 6; // minimum inner width to be readable
|
|
319
|
+
const totalBorders = numCols + 1;
|
|
320
|
+
const totalNatural = colWidths.reduce((a, b) => a + b, 0) + totalBorders;
|
|
321
|
+
if (totalNatural > avail && avail > totalBorders + numCols * MIN_COL) {
|
|
322
|
+
const budgetForCols = avail - totalBorders;
|
|
323
|
+
// Proportional shrink, respecting minimum
|
|
324
|
+
let remaining = budgetForCols;
|
|
325
|
+
const fixed = new Array(numCols).fill(false);
|
|
326
|
+
// First pass: lock columns that are already small (at or below fair share)
|
|
327
|
+
// so they keep their natural width instead of being shrunk further
|
|
328
|
+
const fairShare = Math.floor(budgetForCols / numCols);
|
|
329
|
+
for (let c = 0; c < numCols; c++) {
|
|
330
|
+
if (colWidths[c] <= fairShare) {
|
|
331
|
+
fixed[c] = true;
|
|
332
|
+
remaining -= colWidths[c];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// Second pass: distribute remaining budget proportionally among shrinkable columns
|
|
336
|
+
const shrinkSum = colWidths
|
|
337
|
+
.filter((_w, i) => !fixed[i])
|
|
338
|
+
.reduce((a, b) => a + b, 0);
|
|
339
|
+
if (shrinkSum > 0) {
|
|
340
|
+
let distributed = 0;
|
|
341
|
+
const shrinkable = colWidths.map((_w, i) => !fixed[i]);
|
|
342
|
+
for (let c = 0; c < numCols; c++) {
|
|
343
|
+
if (shrinkable[c]) {
|
|
344
|
+
const share = Math.max(MIN_COL, Math.floor((colWidths[c] / shrinkSum) * remaining));
|
|
345
|
+
colWidths[c] = share;
|
|
346
|
+
distributed += share;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Give any leftover pixels to the last shrinkable column
|
|
350
|
+
const leftover = remaining - distributed;
|
|
351
|
+
if (leftover !== 0) {
|
|
352
|
+
for (let c = numCols - 1; c >= 0; c--) {
|
|
353
|
+
if (shrinkable[c]) {
|
|
354
|
+
colWidths[c] += leftover;
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
315
361
|
const border = theme.tableBorder;
|
|
316
362
|
// Helper to build a horizontal rule
|
|
317
363
|
const hRule = (left, mid, right) => {
|
|
318
364
|
const parts = colWidths.map((w) => "─".repeat(w));
|
|
319
365
|
return left + parts.join(mid) + right;
|
|
320
366
|
};
|
|
321
|
-
//
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
367
|
+
// Word-wrap text to fit within a column width (breaks on spaces)
|
|
368
|
+
const wrapCellText = (text, maxW) => {
|
|
369
|
+
if (maxW <= 0)
|
|
370
|
+
return [text];
|
|
371
|
+
if (text.length <= maxW)
|
|
372
|
+
return [text];
|
|
373
|
+
const wrapped = [];
|
|
374
|
+
let rest = text;
|
|
375
|
+
while (rest.length > maxW) {
|
|
376
|
+
// Find last space within the limit
|
|
377
|
+
let breakAt = rest.lastIndexOf(" ", maxW);
|
|
378
|
+
if (breakAt <= 0) {
|
|
379
|
+
// No space — hard break
|
|
380
|
+
breakAt = maxW;
|
|
381
|
+
wrapped.push(rest.slice(0, breakAt));
|
|
382
|
+
rest = rest.slice(breakAt);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
wrapped.push(rest.slice(0, breakAt));
|
|
386
|
+
rest = rest.slice(breakAt + 1); // skip the space
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (rest.length > 0)
|
|
390
|
+
wrapped.push(rest);
|
|
391
|
+
return wrapped;
|
|
392
|
+
};
|
|
393
|
+
// Helper to build a (possibly multi-line) data row
|
|
394
|
+
const dataRows = (cells, style) => {
|
|
395
|
+
// Get wrapped lines for each cell
|
|
396
|
+
const cellLines = [];
|
|
397
|
+
let maxLines = 1;
|
|
327
398
|
for (let c = 0; c < numCols; c++) {
|
|
328
399
|
const cellText = cells[c] ? plainText(cells[c].tokens) : "";
|
|
329
|
-
const
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
400
|
+
const innerW = colWidths[c] - 2; // 1 char padding each side
|
|
401
|
+
const wrapped = wrapCellText(cellText, innerW);
|
|
402
|
+
cellLines.push(wrapped);
|
|
403
|
+
if (wrapped.length > maxLines)
|
|
404
|
+
maxLines = wrapped.length;
|
|
333
405
|
}
|
|
334
|
-
|
|
406
|
+
const result = [];
|
|
407
|
+
for (let row = 0; row < maxLines; row++) {
|
|
408
|
+
const segs = [
|
|
409
|
+
{ text: indent, style: theme.text },
|
|
410
|
+
{ text: "│", style: border },
|
|
411
|
+
];
|
|
412
|
+
for (let c = 0; c < numCols; c++) {
|
|
413
|
+
const lineText = row < cellLines[c].length ? cellLines[c][row] : "";
|
|
414
|
+
const align = token.header[c]?.align;
|
|
415
|
+
const padded = padCell(lineText, colWidths[c], align);
|
|
416
|
+
segs.push({ text: padded, style });
|
|
417
|
+
segs.push({ text: "│", style: border });
|
|
418
|
+
}
|
|
419
|
+
result.push(segs);
|
|
420
|
+
}
|
|
421
|
+
return result;
|
|
335
422
|
};
|
|
336
423
|
// Top border
|
|
337
424
|
lines.push([
|
|
338
425
|
{ text: indent, style: theme.text },
|
|
339
426
|
{ text: hRule("┌", "┬", "┐"), style: border },
|
|
340
427
|
]);
|
|
341
|
-
// Header row
|
|
342
|
-
|
|
428
|
+
// Header row (may be multi-line)
|
|
429
|
+
for (const line of dataRows(token.header, theme.tableHeader)) {
|
|
430
|
+
lines.push(line);
|
|
431
|
+
}
|
|
343
432
|
// Header separator
|
|
344
433
|
lines.push([
|
|
345
434
|
{ text: indent, style: theme.text },
|
|
346
435
|
{ text: hRule("├", "┼", "┤"), style: border },
|
|
347
436
|
]);
|
|
348
|
-
// Data rows
|
|
437
|
+
// Data rows (may be multi-line each)
|
|
349
438
|
for (const row of token.rows) {
|
|
350
|
-
|
|
439
|
+
for (const line of dataRows(row, theme.text)) {
|
|
440
|
+
lines.push(line);
|
|
441
|
+
}
|
|
351
442
|
}
|
|
352
443
|
// Bottom border
|
|
353
444
|
lines.push([
|
package/package.json
CHANGED