@teammates/consolonia 0.2.7 → 0.3.1

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,15 @@ 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
+ /** Timer for auto-scrolling the feed during drag-to-select. */
149
+ private _selScrollTimer;
150
+ /** Direction of auto-scroll: -1 = up, 1 = down, 0 = none. */
151
+ private _selScrollDir;
152
+ /** DrawingContext reference from the last render (for text extraction). */
153
+ private _ctx;
145
154
  /** Optional widget that replaces the input area (e.g. Interview). */
146
155
  private _inputOverride;
147
156
  constructor(options?: ChatViewOptions);
@@ -235,5 +244,19 @@ export declare class ChatView extends Control {
235
244
  render(ctx: DrawingContext): void;
236
245
  private _renderFeed;
237
246
  private _renderDropdown;
247
+ /** Whether a non-zero text selection is active. */
248
+ private _hasSelection;
249
+ /** Copy the selected text and clear the selection. */
250
+ private _copySelection;
251
+ /** Extract the plain text within the current selection from the pixel buffer. */
252
+ private _getSelectedText;
253
+ /** Render the selection highlight overlay within the feed area. */
254
+ private _renderSelection;
255
+ /** Clear any active text selection. */
256
+ clearSelection(): void;
257
+ /** Start interval-based scroll during drag-to-select at feed edges. */
258
+ private _startSelScroll;
259
+ /** Stop auto-scroll timer. */
260
+ private _stopSelScroll;
238
261
  private _autoScrollToBottom;
239
262
  }
@@ -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,16 @@ 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
+ /** Timer for auto-scrolling the feed during drag-to-select. */
93
+ _selScrollTimer = null;
94
+ /** Direction of auto-scroll: -1 = up, 1 = down, 0 = none. */
95
+ _selScrollDir = 0;
96
+ /** DrawingContext reference from the last render (for text extraction). */
97
+ _ctx = null;
85
98
  /** Optional widget that replaces the input area (e.g. Interview). */
86
99
  _inputOverride = null;
87
100
  constructor(options = {}) {
@@ -452,11 +465,24 @@ export class ChatView extends Control {
452
465
  handleInput(event) {
453
466
  if (event.type === "key") {
454
467
  const ke = event.event;
455
- // Ctrl+C emit for the app to handle
468
+ // Ctrl+C: if selection active, copy; otherwise emit for the app
456
469
  if (ke.key === "c" && ke.ctrl && !ke.alt && !ke.shift) {
470
+ if (this._hasSelection()) {
471
+ this._copySelection();
472
+ return true;
473
+ }
457
474
  this.emit("ctrlc");
458
475
  return true;
459
476
  }
477
+ // Enter: if selection active, copy; otherwise fall through
478
+ if (ke.key === "enter" && this._hasSelection()) {
479
+ this._copySelection();
480
+ return true;
481
+ }
482
+ // Any key clears active selection
483
+ if (this._hasSelection()) {
484
+ this.clearSelection();
485
+ }
460
486
  // Dropdown navigation
461
487
  if (this._dropdownItems.length > 0) {
462
488
  if (ke.key === "up")
@@ -492,7 +518,7 @@ export class ChatView extends Control {
492
518
  return true;
493
519
  }
494
520
  }
495
- // Mouse events: wheel scrolling + scrollbar drag
521
+ // Mouse events: wheel scrolling, scrollbar drag, selection, actions
496
522
  if (event.type === "mouse") {
497
523
  const me = event.event;
498
524
  if (me.type === "wheelup") {
@@ -503,21 +529,21 @@ export class ChatView extends Control {
503
529
  this.scrollFeed(3);
504
530
  return true;
505
531
  }
532
+ // Precompute scrollbar hit for reuse
533
+ const onScrollbar = this._scrollbarVisible &&
534
+ me.x === this._scrollbarX &&
535
+ me.y >= this._feedY &&
536
+ me.y < this._feedY + this._feedH;
506
537
  // Scrollbar drag
507
538
  if (this._scrollbarVisible) {
508
- const onScrollbar = me.x === this._scrollbarX &&
509
- me.y >= this._feedY &&
510
- me.y < this._feedY + this._feedH;
511
539
  if (me.type === "press" && me.button === "left" && onScrollbar) {
512
540
  const relY = me.y - this._feedY;
513
541
  if (relY >= this._thumbPos &&
514
542
  relY < this._thumbPos + this._thumbSize) {
515
- // Clicked on thumb — start dragging
516
543
  this._dragging = true;
517
544
  this._dragOffsetY = relY - this._thumbPos;
518
545
  }
519
546
  else {
520
- // Clicked on track — jump to that position
521
547
  const ratio = relY / this._feedH;
522
548
  this._feedScrollOffset = Math.round(ratio * this._maxScroll);
523
549
  this._feedScrollOffset = Math.max(0, Math.min(this._feedScrollOffset, this._maxScroll));
@@ -540,28 +566,82 @@ export class ChatView extends Control {
540
566
  return true;
541
567
  }
542
568
  }
543
- // Ctrl+click to open URLs in browser
569
+ // Ctrl+click to open URLs or file paths
544
570
  if (me.type === "press" && me.button === "left" && me.ctrl) {
545
571
  const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
546
572
  if (feedLineIdx >= 0) {
547
573
  const text = this._extractFeedLineText(feedLineIdx);
574
+ // Collect all clickable targets: URLs and absolute file paths
548
575
  URL_REGEX.lastIndex = 0;
576
+ FILE_PATH_REGEX.lastIndex = 0;
549
577
  const urls = [...text.matchAll(URL_REGEX)];
550
- if (urls.length === 1) {
551
- this.emit("link", urls[0][0]);
578
+ const paths = [...text.matchAll(FILE_PATH_REGEX)];
579
+ const allTargets = [
580
+ ...urls.map((m) => ({ index: m.index, text: m[0], type: "link" })),
581
+ ...paths.map((m) => ({ index: m.index, text: m[0], type: "file" })),
582
+ ].sort((a, b) => a.index - b.index);
583
+ if (allTargets.length === 1) {
584
+ this.emit(allTargets[0].type, allTargets[0].text);
552
585
  return true;
553
586
  }
554
- if (urls.length > 1) {
555
- // Try to resolve which URL based on click position
587
+ if (allTargets.length > 1) {
556
588
  const row = this._screenToFeedRow.get(me.y) ?? 0;
557
589
  const col = me.x - this._feedX;
558
590
  const charOffset = row * this._contentWidth + col;
559
- const hit = this._findUrlAtOffset(text, charOffset);
560
- this.emit("link", hit ?? urls[0][0]);
591
+ const hit = allTargets.find((t) => charOffset >= t.index && charOffset < t.index + t.text.length);
592
+ const target = hit ?? allTargets[0];
593
+ this.emit(target.type, target.text);
561
594
  return true;
562
595
  }
563
596
  }
564
597
  }
598
+ // Text selection: start on left press in feed area
599
+ if (me.type === "press" &&
600
+ me.button === "left" &&
601
+ !me.ctrl &&
602
+ !onScrollbar) {
603
+ const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
604
+ const isAction = feedLineIdx >= 0 && this._feedActions.has(feedLineIdx);
605
+ if (!isAction) {
606
+ this._selAnchor = { x: me.x, y: me.y };
607
+ this._selEnd = { x: me.x, y: me.y };
608
+ this._selecting = true;
609
+ this.invalidate();
610
+ return true;
611
+ }
612
+ }
613
+ // Text selection: extend on move (with auto-scroll at edges)
614
+ if (me.type === "move" && this._selecting) {
615
+ this._selEnd = { x: me.x, y: me.y };
616
+ const feedTop = this._feedY;
617
+ const feedBot = this._feedY + this._feedH;
618
+ if (me.y < feedTop) {
619
+ this._startSelScroll(-1);
620
+ }
621
+ else if (me.y >= feedBot) {
622
+ this._startSelScroll(1);
623
+ }
624
+ else {
625
+ this._stopSelScroll();
626
+ }
627
+ this.invalidate();
628
+ return true;
629
+ }
630
+ // Text selection: finalize on release
631
+ if (me.type === "release" && this._selecting) {
632
+ this._selecting = false;
633
+ this._stopSelScroll();
634
+ // If anchor == end (just a click, no drag), clear selection
635
+ if (this._selAnchor &&
636
+ this._selEnd &&
637
+ this._selAnchor.x === this._selEnd.x &&
638
+ this._selAnchor.y === this._selEnd.y) {
639
+ this._selAnchor = null;
640
+ this._selEnd = null;
641
+ }
642
+ this.invalidate();
643
+ return true;
644
+ }
565
645
  // Action hover/click in feed area
566
646
  if (this._feedActions.size > 0) {
567
647
  const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
@@ -570,14 +650,12 @@ export class ChatView extends Control {
570
650
  const newHover = entry ? feedLineIdx : -1;
571
651
  if (newHover !== this._hoveredAction ||
572
652
  (entry && entry.items.length > 1)) {
573
- // Restore previous hover
574
653
  if (this._hoveredAction >= 0) {
575
654
  const prev = this._feedActions.get(this._hoveredAction);
576
655
  if (prev) {
577
656
  this._feedLines[this._hoveredAction].lines = [prev.normalStyle];
578
657
  }
579
658
  }
580
- // Apply new hover — highlight only the hovered action item
581
659
  if (entry && newHover >= 0) {
582
660
  const hitItem = this._resolveActionItem(entry, me.x);
583
661
  const hoverLine = this._buildHoverLine(entry, hitItem);
@@ -586,7 +664,6 @@ export class ChatView extends Control {
586
664
  this._hoveredAction = newHover;
587
665
  this.invalidate();
588
666
  }
589
- // Don't consume — let other handlers run too
590
667
  }
591
668
  if (me.type === "press" && me.button === "left" && entry) {
592
669
  const hitItem = this._resolveActionItem(entry, me.x);
@@ -681,6 +758,7 @@ export class ChatView extends Control {
681
758
  }
682
759
  // ── Render ─────────────────────────────────────────────────────
683
760
  render(ctx) {
761
+ this._ctx = ctx;
684
762
  const b = this.bounds;
685
763
  if (!b || b.width < 1 || b.height < 3)
686
764
  return;
@@ -930,6 +1008,10 @@ export class ChatView extends Control {
930
1008
  else {
931
1009
  this._scrollbarVisible = false;
932
1010
  }
1011
+ // Render selection highlight overlay
1012
+ if (this._selAnchor && this._selEnd) {
1013
+ this._renderSelection(ctx, x, y, width, height);
1014
+ }
933
1015
  ctx.popClip();
934
1016
  }
935
1017
  // ── Dropdown rendering ─────────────────────────────────────────
@@ -947,7 +1029,106 @@ export class ChatView extends Control {
947
1029
  ctx.drawText(x, y + i, truncated, style);
948
1030
  }
949
1031
  }
1032
+ // ── Selection ──────────────────────────────────────────────────
1033
+ /** Whether a non-zero text selection is active. */
1034
+ _hasSelection() {
1035
+ return (this._selAnchor !== null &&
1036
+ this._selEnd !== null &&
1037
+ (this._selAnchor.x !== this._selEnd.x ||
1038
+ this._selAnchor.y !== this._selEnd.y));
1039
+ }
1040
+ /** Copy the selected text and clear the selection. */
1041
+ _copySelection() {
1042
+ const text = this._getSelectedText();
1043
+ if (text) {
1044
+ this.emit("copy", text);
1045
+ }
1046
+ this.clearSelection();
1047
+ }
1048
+ /** Extract the plain text within the current selection from the pixel buffer. */
1049
+ _getSelectedText() {
1050
+ if (!this._selAnchor || !this._selEnd || !this._ctx)
1051
+ return "";
1052
+ let startY = this._selAnchor.y;
1053
+ let startX = this._selAnchor.x;
1054
+ let endY = this._selEnd.y;
1055
+ let endX = this._selEnd.x;
1056
+ if (startY > endY || (startY === endY && startX > endX)) {
1057
+ [startY, endY] = [endY, startY];
1058
+ [startX, endX] = [endX, startX];
1059
+ }
1060
+ const lines = [];
1061
+ for (let row = startY; row <= endY; row++) {
1062
+ const colStart = row === startY ? startX : this._feedX;
1063
+ const colEnd = row === endY ? endX : this._feedX + this._contentWidth - 1;
1064
+ let line = "";
1065
+ for (let col = colStart; col <= colEnd; col++) {
1066
+ const ch = this._ctx.readCharAbsolute(col, row);
1067
+ line += ch || " ";
1068
+ }
1069
+ lines.push(line.trimEnd());
1070
+ }
1071
+ return lines.join("\n");
1072
+ }
1073
+ /** Render the selection highlight overlay within the feed area. */
1074
+ _renderSelection(ctx, feedX, feedY, feedW, feedH) {
1075
+ if (!this._selAnchor || !this._selEnd)
1076
+ return;
1077
+ let startY = this._selAnchor.y;
1078
+ let startX = this._selAnchor.x;
1079
+ let endY = this._selEnd.y;
1080
+ let endX = this._selEnd.x;
1081
+ if (startY > endY || (startY === endY && startX > endX)) {
1082
+ [startY, endY] = [endY, startY];
1083
+ [startX, endX] = [endX, startX];
1084
+ }
1085
+ // Clamp to feed area
1086
+ startY = Math.max(startY, feedY);
1087
+ endY = Math.min(endY, feedY + feedH - 1);
1088
+ for (let row = startY; row <= endY; row++) {
1089
+ const colStart = row === startY ? startX : feedX;
1090
+ const colEnd = row === endY ? endX : feedX + feedW - 2;
1091
+ for (let col = colStart; col <= colEnd; col++) {
1092
+ ctx.highlightCell(col, row, SELECTION_BG);
1093
+ }
1094
+ }
1095
+ }
1096
+ /** Clear any active text selection. */
1097
+ clearSelection() {
1098
+ this._selAnchor = null;
1099
+ this._selEnd = null;
1100
+ this._selecting = false;
1101
+ this._stopSelScroll();
1102
+ this.invalidate();
1103
+ }
950
1104
  // ── Auto-scroll ────────────────────────────────────────────────
1105
+ /** Start interval-based scroll during drag-to-select at feed edges. */
1106
+ _startSelScroll(dir) {
1107
+ if (this._selScrollDir === dir && this._selScrollTimer)
1108
+ return;
1109
+ this._stopSelScroll();
1110
+ this._selScrollDir = dir;
1111
+ this._selScrollTimer = setInterval(() => {
1112
+ this.scrollFeed(this._selScrollDir * 3);
1113
+ // Move selEnd to keep extending the selection while scrolling
1114
+ if (this._selEnd) {
1115
+ this._selEnd = {
1116
+ x: this._selEnd.x,
1117
+ y: this._selScrollDir < 0
1118
+ ? this._feedY
1119
+ : this._feedY + this._feedH - 1,
1120
+ };
1121
+ }
1122
+ }, 80);
1123
+ }
1124
+ /** Stop auto-scroll timer. */
1125
+ _stopSelScroll() {
1126
+ if (this._selScrollTimer) {
1127
+ clearInterval(this._selScrollTimer);
1128
+ this._selScrollTimer = null;
1129
+ }
1130
+ this._selScrollDir = 0;
1131
+ }
951
1132
  _autoScrollToBottom() {
952
1133
  // Set scroll to a very large value; it will be clamped during render
953
1134
  this._feedScrollOffset = Number.MAX_SAFE_INTEGER;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teammates/consolonia",
3
- "version": "0.2.7",
3
+ "version": "0.3.1",
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",