@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.
@@ -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
  }
@@ -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 emit for the app to handle
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 + scrollbar drag
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 in browser
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
- if (urls.length === 1) {
551
- this.emit("link", urls[0][0]);
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 (urls.length > 1) {
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 = this._findUrlAtOffset(text, charOffset);
560
- this.emit("link", hit ?? urls[0][0]);
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
@@ -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, _width, indent) {
300
+ function renderTable(token, lines, theme, width, indent) {
301
301
  const numCols = token.header.length;
302
- // Compute column widths from content
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
- // Helper to build a data row
322
- const dataRow = (cells, style) => {
323
- const segs = [
324
- { text: indent, style: theme.text },
325
- { text: "│", style: border },
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 align = token.header[c]?.align;
330
- const padded = padCell(cellText, colWidths[c], align);
331
- segs.push({ text: padded, style });
332
- segs.push({ text: "│", style: border });
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
- return segs;
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
- lines.push(dataRow(token.header, theme.tableHeader));
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
- lines.push(dataRow(row, theme.text));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teammates/consolonia",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Terminal UI rendering engine inspired by Consolonia. Pixel-level compositing with ANSI output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",