@teammates/consolonia 0.6.2 → 0.7.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/index.d.ts CHANGED
@@ -35,13 +35,15 @@ export { Control } from "./layout/control.js";
35
35
  export { Row, type RowOptions } from "./layout/row.js";
36
36
  export { Stack, type StackOptions } from "./layout/stack.js";
37
37
  export { Border, type BorderOptions } from "./widgets/border.js";
38
- export { ChatView, type ChatViewOptions, type DropdownItem, type FeedActionItem, } from "./widgets/chat-view.js";
38
+ export { ChatView, type ChatViewOptions, type DropdownItem, type FeedActionEntry, type FeedActionItem, type FeedItem, } from "./widgets/chat-view.js";
39
+ export { FeedStore } from "./widgets/feed-store.js";
39
40
  export { Interview, type InterviewOptions, type InterviewQuestion, } from "./widgets/interview.js";
40
41
  export { Panel, type PanelOptions } from "./widgets/panel.js";
41
42
  export { ScrollView, type ScrollViewOptions } from "./widgets/scroll-view.js";
42
43
  export { type StyledLine, StyledText, type StyledTextOptions, } from "./widgets/styled-text.js";
43
44
  export { Text, type TextOptions } from "./widgets/text.js";
44
45
  export { type DeleteSizer, type InputColorizer, TextInput, type TextInputOptions, } from "./widgets/text-input.js";
46
+ export { VirtualList, type VirtualListItem, type VirtualListOptions, } from "./widgets/virtual-list.js";
45
47
  export { type MarkdownOptions, type MarkdownTheme, renderMarkdown, } from "./widgets/markdown.js";
46
48
  export { DEFAULT_SYNTAX_THEME, getHighlighter, highlightLine, registerHighlighter, type SyntaxHighlighter, type SyntaxTheme, type SyntaxToken, type SyntaxTokenType, } from "./widgets/syntax.js";
47
49
  export { concat, isStyledSpan, pen, type StyledSegment, type StyledSpan, spanLength, spanText, } from "./styled.js";
package/dist/index.js CHANGED
@@ -47,12 +47,14 @@ export { Stack } from "./layout/stack.js";
47
47
  // ── Widgets ─────────────────────────────────────────────────────────
48
48
  export { Border } from "./widgets/border.js";
49
49
  export { ChatView, } from "./widgets/chat-view.js";
50
+ export { FeedStore } from "./widgets/feed-store.js";
50
51
  export { Interview, } from "./widgets/interview.js";
51
52
  export { Panel } from "./widgets/panel.js";
52
53
  export { ScrollView } from "./widgets/scroll-view.js";
53
54
  export { StyledText, } from "./widgets/styled-text.js";
54
55
  export { Text } from "./widgets/text.js";
55
56
  export { TextInput, } from "./widgets/text-input.js";
57
+ export { VirtualList, } from "./widgets/virtual-list.js";
56
58
  // ── Markdown ─────────────────────────────────────────────────────────
57
59
  export { renderMarkdown, } from "./widgets/markdown.js";
58
60
  // ── Syntax highlighting ──────────────────────────────────────────────
@@ -34,6 +34,7 @@ import type { InputEvent } from "../input/events.js";
34
34
  import { Control } from "../layout/control.js";
35
35
  import type { Constraint, Rect, Size } from "../layout/types.js";
36
36
  import type { StyledSpan } from "../styled.js";
37
+ import { type FeedActionItem } from "./feed-store.js";
37
38
  import { type StyledLine } from "./styled-text.js";
38
39
  import { type DeleteSizer, type InputColorizer, TextInput } from "./text-input.js";
39
40
  export interface DropdownItem {
@@ -44,12 +45,7 @@ export interface DropdownItem {
44
45
  /** Full text to insert on accept. */
45
46
  completion: string;
46
47
  }
47
- /** A single action item within an action line. */
48
- export interface FeedActionItem {
49
- id: string;
50
- normalStyle: StyledLine;
51
- hoverStyle: StyledLine;
52
- }
48
+ export type { FeedActionEntry, FeedActionItem, FeedItem, } from "./feed-store.js";
53
49
  export interface ChatViewOptions {
54
50
  /** Banner text shown at the top of the chat area. */
55
51
  banner?: string;
@@ -105,15 +101,14 @@ export interface ChatViewOptions {
105
101
  export declare class ChatView extends Control {
106
102
  private _banner;
107
103
  private _topSeparator;
108
- private _feedLines;
109
- /** Maps feed line index → action(s) for clickable lines. */
110
- private _feedActions;
111
- /** Feed line index currently hovered (-1 if none). */
112
- private _hoveredAction;
113
- /** Maps screen Y → feed line index (rebuilt each render). */
114
- private _screenToFeedLine;
115
- /** Maps screen Y → row offset within the feed line (for multi-row wrapped lines). */
116
- private _screenToFeedRow;
104
+ /** Identity-based feed item store — replaces _feedLines + _feedActions + _hiddenFeedLines. */
105
+ private _store;
106
+ /** ID of the feed item currently hovered (null if none). */
107
+ private _hoveredItemId;
108
+ /** Scrollable list widget — owns scroll state, height cache, screen mapping, scrollbar. */
109
+ private _feed;
110
+ /** Number of non-feed items (banner + separator) prepended to VirtualList items. */
111
+ private _feedItemOffset;
117
112
  private _bottomSeparator;
118
113
  private _progressText;
119
114
  private _input;
@@ -130,25 +125,6 @@ export declare class ChatView extends Control {
130
125
  private _dropdownStyle;
131
126
  private _footerStyle;
132
127
  private _maxInputH;
133
- private _feedScrollOffset;
134
- private _feedX;
135
- private _contentWidth;
136
- /** Cached measured height per feed line index. Invalidated on width change. */
137
- private _feedHeightCache;
138
- /** The content width used for the last height cache pass. */
139
- private _feedHeightCacheWidth;
140
- /** Cached from last render for hit-testing. */
141
- private _scrollbarX;
142
- private _feedY;
143
- private _feedH;
144
- private _thumbPos;
145
- private _thumbSize;
146
- private _maxScroll;
147
- private _scrollbarVisible;
148
- /** True while the user is dragging the scrollbar thumb. */
149
- private _dragging;
150
- /** The Y offset within the thumb where the drag started. */
151
- private _dragOffsetY;
152
128
  private _selAnchor;
153
129
  private _selEnd;
154
130
  private _selecting;
@@ -195,6 +171,20 @@ export declare class ChatView extends Control {
195
171
  get feedLineCount(): number;
196
172
  /** Update the content of an existing feed line by index. Also removes its action if any. */
197
173
  updateFeedLine(index: number, content: StyledLine): void;
174
+ /** Update the action items on an existing action line by index. */
175
+ updateActionList(index: number, actions: FeedActionItem[]): void;
176
+ /** Insert a plain text line at a specific feed index. */
177
+ insertToFeed(atIndex: number, text: string, style?: TextStyle): void;
178
+ /** Insert a styled line at a specific feed index. */
179
+ insertStyledToFeed(atIndex: number, styledLine: StyledSpan): void;
180
+ /** Insert an action list at a specific feed index. */
181
+ insertActionList(atIndex: number, actions: FeedActionItem[]): void;
182
+ /** Hide or show a single feed line. Hidden lines take zero height. */
183
+ setFeedLineHidden(index: number, hidden: boolean): void;
184
+ /** Hide or show a range of feed lines. */
185
+ setFeedLinesHidden(startIndex: number, count: number, hidden: boolean): void;
186
+ /** Check if a feed line is hidden. */
187
+ isFeedLineHidden(index: number): boolean;
198
188
  /** Scroll the feed to the bottom. */
199
189
  scrollToBottom(): void;
200
190
  /** Scroll the feed by a delta (positive = down, negative = up). */
@@ -238,6 +228,8 @@ export declare class ChatView extends Control {
238
228
  /** Get the current input override widget, or null. */
239
229
  get inputOverride(): Control | null;
240
230
  handleInput(event: InputEvent): boolean;
231
+ /** Map screen Y → feed line index (accounting for banner/separator prefix items). */
232
+ private _feedLineAtScreen;
241
233
  /** Extract the plain text content of a feed line. */
242
234
  private _extractFeedLineText;
243
235
  /** Resolve which action item the mouse x-position falls on. */
@@ -249,7 +241,8 @@ export declare class ChatView extends Control {
249
241
  measure(constraint: Constraint): Size;
250
242
  arrange(rect: Rect): void;
251
243
  render(ctx: DrawingContext): void;
252
- private _renderFeed;
244
+ /** Build the VirtualList items array from banner + separator + feed store items. */
245
+ private _buildVirtualListItems;
253
246
  private _renderDropdown;
254
247
  /** Whether a non-zero text selection is active. */
255
248
  private _hasSelection;
@@ -30,9 +30,11 @@
30
30
  * "tab" () — user pressed Tab (for autocomplete)
31
31
  */
32
32
  import { Control } from "../layout/control.js";
33
+ import { FeedStore, } from "./feed-store.js";
33
34
  import { StyledText } from "./styled-text.js";
34
35
  import { Text } from "./text.js";
35
36
  import { TextInput, } from "./text-input.js";
37
+ import { VirtualList } from "./virtual-list.js";
36
38
  // ── URL / file path detection ───────────────────────────────────────
37
39
  const URL_REGEX = /https?:\/\/[^\s)>\]]+/g;
38
40
  const FILE_PATH_REGEX = /(?:[A-Za-z]:[/\\]|\/)[^\s:*?"<>|)>\]]*[^\s:*?"<>|)>\].,:;!]/g;
@@ -43,15 +45,14 @@ export class ChatView extends Control {
43
45
  // ── Child controls ─────────────────────────────────────────────
44
46
  _banner;
45
47
  _topSeparator;
46
- _feedLines = [];
47
- /** Maps feed line index → action(s) for clickable lines. */
48
- _feedActions = new Map();
49
- /** Feed line index currently hovered (-1 if none). */
50
- _hoveredAction = -1;
51
- /** Maps screen Y → feed line index (rebuilt each render). */
52
- _screenToFeedLine = new Map();
53
- /** Maps screen Y → row offset within the feed line (for multi-row wrapped lines). */
54
- _screenToFeedRow = new Map();
48
+ /** Identity-based feed item store — replaces _feedLines + _feedActions + _hiddenFeedLines. */
49
+ _store = new FeedStore();
50
+ /** ID of the feed item currently hovered (null if none). */
51
+ _hoveredItemId = null;
52
+ /** Scrollable list widget — owns scroll state, height cache, screen mapping, scrollbar. */
53
+ _feed;
54
+ /** Number of non-feed items (banner + separator) prepended to VirtualList items. */
55
+ _feedItemOffset = 0;
55
56
  _bottomSeparator;
56
57
  _progressText;
57
58
  _input;
@@ -69,28 +70,6 @@ export class ChatView extends Control {
69
70
  _dropdownStyle;
70
71
  _footerStyle;
71
72
  _maxInputH;
72
- _feedScrollOffset = 0;
73
- // ── Feed geometry (cached from last render for hit-testing) ──
74
- _feedX = 0;
75
- _contentWidth = 0;
76
- // ── Feed line height cache ───────────────────────────────────
77
- /** Cached measured height per feed line index. Invalidated on width change. */
78
- _feedHeightCache = [];
79
- /** The content width used for the last height cache pass. */
80
- _feedHeightCacheWidth = -1;
81
- // ── Scrollbar state ───────────────────────────────────────────
82
- /** Cached from last render for hit-testing. */
83
- _scrollbarX = -1;
84
- _feedY = 0;
85
- _feedH = 0;
86
- _thumbPos = 0;
87
- _thumbSize = 0;
88
- _maxScroll = 0;
89
- _scrollbarVisible = false;
90
- /** True while the user is dragging the scrollbar thumb. */
91
- _dragging = false;
92
- /** The Y offset within the thumb where the drag started. */
93
- _dragOffsetY = 0;
94
73
  // ── Selection state ──────────────────────────────────────────
95
74
  _selAnchor = null;
96
75
  _selEnd = null;
@@ -130,6 +109,17 @@ export class ChatView extends Control {
130
109
  // Top separator (between banner and feed)
131
110
  this._topSeparator = new _Separator(this._separatorChar, this._separatorStyle);
132
111
  this.addChild(this._topSeparator);
112
+ // Virtual list (scrollable feed area — owns scroll, height cache, scrollbar)
113
+ this._feed = new VirtualList({
114
+ trackStyle: this._separatorStyle,
115
+ thumbStyle: this._feedStyle,
116
+ });
117
+ this._feed.onRenderOverlay = (ctx, x, y, w, h) => {
118
+ if (this._selAnchor && this._selEnd) {
119
+ this._renderSelection(ctx, x, y, w, h);
120
+ }
121
+ };
122
+ this.addChild(this._feed);
133
123
  // Bottom separator (between feed and input area)
134
124
  this._bottomSeparator = new _Separator(this._separatorChar, this._separatorStyle);
135
125
  this.addChild(this._bottomSeparator);
@@ -247,36 +237,34 @@ export class ChatView extends Control {
247
237
  // ── Public API: Feed ───────────────────────────────────────────
248
238
  /** Append a line of plain text to the feed. Auto-scrolls to bottom. */
249
239
  appendToFeed(text, style) {
250
- const line = new StyledText({
240
+ const content = new StyledText({
251
241
  lines: [text],
252
242
  defaultStyle: style ?? this._feedStyle,
253
243
  wrap: true,
254
244
  });
255
- this._feedLines.push(line);
245
+ this._store.push(content);
256
246
  this._autoScrollToBottom();
257
247
  this.invalidate();
258
248
  }
259
249
  /** Append a styled line (StyledSpan) to the feed. */
260
250
  appendStyledToFeed(styledLine) {
261
- const line = new StyledText({
251
+ const content = new StyledText({
262
252
  lines: [styledLine],
263
253
  defaultStyle: this._feedStyle,
264
254
  wrap: true,
265
255
  });
266
- this._feedLines.push(line);
256
+ this._store.push(content);
267
257
  this._autoScrollToBottom();
268
258
  this.invalidate();
269
259
  }
270
260
  /** Append a clickable action line to the feed. Emits "action" on click. */
271
261
  appendAction(id, normalContent, hoverContent) {
272
- const line = new StyledText({
262
+ const content = new StyledText({
273
263
  lines: [normalContent],
274
264
  defaultStyle: this._feedStyle,
275
265
  wrap: false,
276
266
  });
277
- const idx = this._feedLines.length;
278
- this._feedLines.push(line);
279
- this._feedActions.set(idx, {
267
+ this._store.push(content, {
280
268
  items: [{ id, normalStyle: normalContent, hoverStyle: hoverContent }],
281
269
  normalStyle: normalContent,
282
270
  });
@@ -288,14 +276,12 @@ export class ChatView extends Control {
288
276
  if (actions.length === 0)
289
277
  return;
290
278
  const combined = this._concatSpans(actions.map((a) => a.normalStyle));
291
- const line = new StyledText({
279
+ const content = new StyledText({
292
280
  lines: [combined],
293
281
  defaultStyle: this._feedStyle,
294
282
  wrap: false,
295
283
  });
296
- const idx = this._feedLines.length;
297
- this._feedLines.push(line);
298
- this._feedActions.set(idx, { items: actions, normalStyle: combined });
284
+ this._store.push(content, { items: actions, normalStyle: combined });
299
285
  this._autoScrollToBottom();
300
286
  this.invalidate();
301
287
  }
@@ -313,49 +299,124 @@ export class ChatView extends Control {
313
299
  /** Append multiple plain lines to the feed. */
314
300
  appendLines(lines, style) {
315
301
  for (const text of lines) {
316
- const line = new StyledText({
302
+ const content = new StyledText({
317
303
  lines: [text],
318
304
  defaultStyle: style ?? this._feedStyle,
319
305
  wrap: true,
320
306
  });
321
- this._feedLines.push(line);
307
+ this._store.push(content);
322
308
  }
323
309
  this._autoScrollToBottom();
324
310
  this.invalidate();
325
311
  }
326
312
  /** Clear everything between the banner and the input box. */
327
313
  clear() {
328
- this._feedLines = [];
329
- this._feedHeightCache = [];
330
- this._feedActions.clear();
331
- this._hoveredAction = -1;
332
- this._feedScrollOffset = 0;
314
+ this._store.clear();
315
+ this._hoveredItemId = null;
316
+ this._feed.reset();
333
317
  this.invalidate();
334
318
  }
335
319
  /** Total number of feed lines. */
336
320
  get feedLineCount() {
337
- return this._feedLines.length;
321
+ return this._store.length;
338
322
  }
339
323
  /** Update the content of an existing feed line by index. Also removes its action if any. */
340
324
  updateFeedLine(index, content) {
341
- if (index < 0 || index >= this._feedLines.length)
325
+ const item = this._store.at(index);
326
+ if (!item)
327
+ return;
328
+ item.content.lines = [content];
329
+ this._feed.invalidateItem(item.id);
330
+ item.actions = undefined;
331
+ if (this._hoveredItemId === item.id)
332
+ this._hoveredItemId = null;
333
+ this.invalidate();
334
+ }
335
+ /** Update the action items on an existing action line by index. */
336
+ updateActionList(index, actions) {
337
+ const item = this._store.at(index);
338
+ if (!item)
339
+ return;
340
+ if (actions.length === 0)
341
+ return;
342
+ const combined = this._concatSpans(actions.map((a) => a.normalStyle));
343
+ item.content.lines = [combined];
344
+ this._feed.invalidateItem(item.id);
345
+ item.actions = { items: actions, normalStyle: combined };
346
+ if (this._hoveredItemId === item.id)
347
+ this._hoveredItemId = null;
348
+ this.invalidate();
349
+ }
350
+ // ── Insert API ──────────────────────────────────────────────────
351
+ // No _shiftFeedIndices needed — FeedStore handles the single array splice.
352
+ /** Insert a plain text line at a specific feed index. */
353
+ insertToFeed(atIndex, text, style) {
354
+ const content = new StyledText({
355
+ lines: [text],
356
+ defaultStyle: style ?? this._feedStyle,
357
+ wrap: true,
358
+ });
359
+ this._store.insert(atIndex, content);
360
+ this._autoScrollToBottom();
361
+ this.invalidate();
362
+ }
363
+ /** Insert a styled line at a specific feed index. */
364
+ insertStyledToFeed(atIndex, styledLine) {
365
+ const content = new StyledText({
366
+ lines: [styledLine],
367
+ defaultStyle: this._feedStyle,
368
+ wrap: true,
369
+ });
370
+ this._store.insert(atIndex, content);
371
+ this._autoScrollToBottom();
372
+ this.invalidate();
373
+ }
374
+ /** Insert an action list at a specific feed index. */
375
+ insertActionList(atIndex, actions) {
376
+ if (actions.length === 0)
342
377
  return;
343
- this._feedLines[index].lines = [content];
344
- // Invalidate cached height — content changed, may wrap differently
345
- delete this._feedHeightCache[index];
346
- this._feedActions.delete(index);
347
- if (this._hoveredAction === index)
348
- this._hoveredAction = -1;
378
+ const combined = this._concatSpans(actions.map((a) => a.normalStyle));
379
+ const content = new StyledText({
380
+ lines: [combined],
381
+ defaultStyle: this._feedStyle,
382
+ wrap: false,
383
+ });
384
+ this._store.insert(atIndex, content, {
385
+ items: actions,
386
+ normalStyle: combined,
387
+ });
388
+ this._autoScrollToBottom();
389
+ this.invalidate();
390
+ }
391
+ // ── Visibility API ────────────────────────────────────────────────
392
+ /** Hide or show a single feed line. Hidden lines take zero height. */
393
+ setFeedLineHidden(index, hidden) {
394
+ const item = this._store.at(index);
395
+ if (item)
396
+ item.hidden = hidden;
349
397
  this.invalidate();
350
398
  }
399
+ /** Hide or show a range of feed lines. */
400
+ setFeedLinesHidden(startIndex, count, hidden) {
401
+ for (let i = startIndex; i < startIndex + count; i++) {
402
+ const item = this._store.at(i);
403
+ if (item)
404
+ item.hidden = hidden;
405
+ }
406
+ this.invalidate();
407
+ }
408
+ /** Check if a feed line is hidden. */
409
+ isFeedLineHidden(index) {
410
+ return this._store.at(index)?.hidden === true;
411
+ }
351
412
  /** Scroll the feed to the bottom. */
352
413
  scrollToBottom() {
353
- this._autoScrollToBottom();
414
+ this._feed.scrollToBottom();
354
415
  this.invalidate();
355
416
  }
356
417
  /** Scroll the feed by a delta (positive = down, negative = up). */
357
418
  scrollFeed(delta) {
358
- this._feedScrollOffset = Math.max(0, this._feedScrollOffset + delta);
419
+ this._feed.scroll(delta);
359
420
  // Clear selection when scrolling (unless actively drag-selecting)
360
421
  if (!this._selecting && this._hasSelection()) {
361
422
  this.clearSelection();
@@ -548,6 +609,7 @@ export class ChatView extends Control {
548
609
  // Mouse events: wheel scrolling, scrollbar drag, selection, actions
549
610
  if (event.type === "mouse") {
550
611
  const me = event.event;
612
+ const fb = this._feed.bounds;
551
613
  if (me.type === "wheelup") {
552
614
  this.scrollFeed(-3);
553
615
  return true;
@@ -557,49 +619,35 @@ export class ChatView extends Control {
557
619
  return true;
558
620
  }
559
621
  // Precompute scrollbar hit for reuse
560
- const onScrollbar = this._scrollbarVisible &&
561
- me.x === this._scrollbarX &&
562
- me.y >= this._feedY &&
563
- me.y < this._feedY + this._feedH;
564
- // Scrollbar drag
565
- if (this._scrollbarVisible) {
622
+ const onScrollbar = fb != null &&
623
+ this._feed.scrollbarVisible &&
624
+ me.x === this._feed.scrollbarX &&
625
+ me.y >= fb.y &&
626
+ me.y < fb.y + fb.height;
627
+ // Scrollbar drag — delegate to VirtualList
628
+ if (this._feed.scrollbarVisible) {
566
629
  if (me.type === "press" && me.button === "left" && onScrollbar) {
567
- const relY = me.y - this._feedY;
568
- if (relY >= this._thumbPos &&
569
- relY < this._thumbPos + this._thumbSize) {
570
- this._dragging = true;
571
- this._dragOffsetY = relY - this._thumbPos;
572
- }
573
- else {
574
- const ratio = relY / this._feedH;
575
- this._feedScrollOffset = Math.round(ratio * this._maxScroll);
576
- this._feedScrollOffset = Math.max(0, Math.min(this._feedScrollOffset, this._maxScroll));
577
- if (this._hasSelection())
578
- this.clearSelection();
579
- this.invalidate();
580
- }
630
+ this._feed.handleScrollbarPress(me.y);
631
+ if (this._hasSelection())
632
+ this.clearSelection();
633
+ this.invalidate();
581
634
  return true;
582
635
  }
583
- if (me.type === "move" && this._dragging) {
584
- const relY = me.y - this._feedY;
585
- const newThumbPos = relY - this._dragOffsetY;
586
- const maxThumbPos = this._feedH - this._thumbSize;
587
- const clampedPos = Math.max(0, Math.min(newThumbPos, maxThumbPos));
588
- const ratio = maxThumbPos > 0 ? clampedPos / maxThumbPos : 0;
589
- this._feedScrollOffset = Math.round(ratio * this._maxScroll);
636
+ if (me.type === "move" && this._feed.isDragging) {
637
+ this._feed.handleScrollbarDrag(me.y);
590
638
  if (this._hasSelection())
591
639
  this.clearSelection();
592
640
  this.invalidate();
593
641
  return true;
594
642
  }
595
- if (me.type === "release" && this._dragging) {
596
- this._dragging = false;
643
+ if (me.type === "release" && this._feed.isDragging) {
644
+ this._feed.handleScrollbarRelease();
597
645
  return true;
598
646
  }
599
647
  }
600
648
  // Ctrl+click to open URLs or file paths
601
649
  if (me.type === "press" && me.button === "left" && me.ctrl) {
602
- const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
650
+ const feedLineIdx = this._feedLineAtScreen(me.y);
603
651
  if (feedLineIdx >= 0) {
604
652
  const text = this._extractFeedLineText(feedLineIdx);
605
653
  // Collect all clickable targets: URLs and absolute file paths
@@ -624,9 +672,9 @@ export class ChatView extends Control {
624
672
  return true;
625
673
  }
626
674
  if (allTargets.length > 1) {
627
- const row = this._screenToFeedRow.get(me.y) ?? 0;
628
- const col = me.x - this._feedX;
629
- const charOffset = row * this._contentWidth + col;
675
+ const row = this._feed.rowAtScreen(me.y);
676
+ const col = me.x - (fb?.x ?? 0);
677
+ const charOffset = row * this._feed.contentWidth + col;
630
678
  const hit = allTargets.find((t) => charOffset >= t.index && charOffset < t.index + t.text.length);
631
679
  const target = hit ?? allTargets[0];
632
680
  this.emit(target.type, target.text);
@@ -639,8 +687,8 @@ export class ChatView extends Control {
639
687
  me.button === "left" &&
640
688
  !me.ctrl &&
641
689
  !onScrollbar) {
642
- const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
643
- const isAction = feedLineIdx >= 0 && this._feedActions.has(feedLineIdx);
690
+ const feedLineIdx = this._feedLineAtScreen(me.y);
691
+ const isAction = feedLineIdx >= 0 && !!this._store.at(feedLineIdx)?.actions;
644
692
  if (!isAction) {
645
693
  this._selAnchor = { x: me.x, y: me.y };
646
694
  this._selEnd = { x: me.x, y: me.y };
@@ -652,8 +700,8 @@ export class ChatView extends Control {
652
700
  // Text selection: extend on move (with auto-scroll at edges)
653
701
  if (me.type === "move" && this._selecting) {
654
702
  this._selEnd = { x: me.x, y: me.y };
655
- const feedTop = this._feedY;
656
- const feedBot = this._feedY + this._feedH;
703
+ const feedTop = fb?.y ?? 0;
704
+ const feedBot = feedTop + (fb?.height ?? 0);
657
705
  if (me.y < feedTop) {
658
706
  this._startSelScroll(-1);
659
707
  }
@@ -683,27 +731,30 @@ export class ChatView extends Control {
683
731
  return true;
684
732
  }
685
733
  // Action hover/click in feed area
686
- if (this._feedActions.size > 0) {
687
- const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
688
- const entry = feedLineIdx >= 0 ? this._feedActions.get(feedLineIdx) : undefined;
734
+ if (this._store.hasActions) {
735
+ const feedLineIdx = this._feedLineAtScreen(me.y);
736
+ const feedItem = feedLineIdx >= 0 ? this._store.at(feedLineIdx) : undefined;
737
+ const entry = feedItem?.actions;
689
738
  if (me.type === "move") {
690
- const newHover = entry ? feedLineIdx : -1;
691
- if (newHover !== this._hoveredAction ||
739
+ const newHoverId = entry ? feedItem.id : null;
740
+ if (newHoverId !== this._hoveredItemId ||
692
741
  (entry && entry.items.length > 1)) {
693
- if (this._hoveredAction >= 0) {
694
- const prev = this._feedActions.get(this._hoveredAction);
695
- if (prev) {
696
- this._feedLines[this._hoveredAction].lines = [prev.normalStyle];
697
- delete this._feedHeightCache[this._hoveredAction];
742
+ // Restore previous hover item to normal style
743
+ if (this._hoveredItemId) {
744
+ const prevItem = this._store.get(this._hoveredItemId);
745
+ if (prevItem?.actions) {
746
+ prevItem.content.lines = [prevItem.actions.normalStyle];
747
+ this._feed.invalidateItem(prevItem.id);
698
748
  }
699
749
  }
700
- if (entry && newHover >= 0) {
750
+ // Apply hover style to new item
751
+ if (entry && feedItem) {
701
752
  const hitItem = this._resolveActionItem(entry, me.x);
702
753
  const hoverLine = this._buildHoverLine(entry, hitItem);
703
- this._feedLines[newHover].lines = [hoverLine];
704
- delete this._feedHeightCache[newHover];
754
+ feedItem.content.lines = [hoverLine];
755
+ this._feed.invalidateItem(feedItem.id);
705
756
  }
706
- this._hoveredAction = newHover;
757
+ this._hoveredItemId = newHoverId;
707
758
  this.invalidate();
708
759
  }
709
760
  }
@@ -721,9 +772,16 @@ export class ChatView extends Control {
721
772
  }
722
773
  return this._input.handleInput(event);
723
774
  }
775
+ /** Map screen Y → feed line index (accounting for banner/separator prefix items). */
776
+ _feedLineAtScreen(screenY) {
777
+ const itemIdx = this._feed.itemIndexAtScreen(screenY);
778
+ return itemIdx >= this._feedItemOffset
779
+ ? itemIdx - this._feedItemOffset
780
+ : -1;
781
+ }
724
782
  /** Extract the plain text content of a feed line. */
725
783
  _extractFeedLineText(idx) {
726
- const styledText = this._feedLines[idx];
784
+ const styledText = this._store.at(idx)?.content;
727
785
  if (!styledText)
728
786
  return "";
729
787
  return styledText.lines
@@ -794,6 +852,8 @@ export class ChatView extends Control {
794
852
  return;
795
853
  const W = b.width;
796
854
  const H = b.height;
855
+ // Build VirtualList items: banner + separator + feed items
856
+ this._buildVirtualListItems();
797
857
  // ── Measure fixed-height sections ────────────────────────
798
858
  // Progress text height (always 1 row when visible)
799
859
  let progressH = 0;
@@ -815,9 +875,10 @@ export class ChatView extends Control {
815
875
  const chromeH = botSepH + progressH + overrideH;
816
876
  const feedH = Math.max(0, H - chromeH);
817
877
  let y = b.y;
818
- // 1. Feed area
878
+ // 1. Feed area — delegate to VirtualList
819
879
  if (feedH > 0) {
820
- this._renderFeed(ctx, b.x, y, W, feedH);
880
+ this._feed.arrange({ x: b.x, y, width: W, height: feedH });
881
+ this._feed.render(ctx);
821
882
  y += feedH;
822
883
  }
823
884
  // 2. Progress text
@@ -876,9 +937,10 @@ export class ChatView extends Control {
876
937
  const feedH = Math.max(0, H - fixedH);
877
938
  // ── Arrange and render each section ──────────────────────
878
939
  let y = b.y;
879
- // 1. Feed area (banner + separator + feed lines all scroll together)
940
+ // 1. Feed area delegate to VirtualList
880
941
  if (feedH > 0) {
881
- this._renderFeed(ctx, b.x, y, W, feedH);
942
+ this._feed.arrange({ x: b.x, y, width: W, height: feedH });
943
+ this._feed.render(ctx);
882
944
  y += feedH;
883
945
  }
884
946
  // 2. Progress text (above separator, fixed — not part of scrollable feed)
@@ -939,138 +1001,20 @@ export class ChatView extends Control {
939
1001
  }
940
1002
  }
941
1003
  }
942
- // ── Feed rendering ─────────────────────────────────────────────
943
- _renderFeed(ctx, x, y, width, height) {
944
- // Build the list of scrollable items: banner + separator + feed lines
945
- // Each item is { control, height } measured against content width.
946
- const contentWidth = width - 1; // reserve 1 col for scrollbar
947
- this._feedX = x;
948
- this._contentWidth = contentWidth;
1004
+ /** Build the VirtualList items array from banner + separator + feed store items. */
1005
+ _buildVirtualListItems() {
949
1006
  const items = [];
950
- // Banner (if visible)
951
1007
  if (this._banner.visible) {
952
- const bannerSize = this._banner.measure({
953
- minWidth: 0,
954
- maxWidth: contentWidth,
955
- minHeight: 0,
956
- maxHeight: Infinity,
957
- });
958
- const bh = Math.max(1, bannerSize.height);
959
- items.push({
960
- height: bh,
961
- feedLineIdx: -1,
962
- render: (cx, cy, cw, ch) => {
963
- this._banner.arrange({ x: cx, y: cy, width: cw, height: ch });
964
- this._banner.render(ctx);
965
- },
966
- });
967
- // Top separator after banner
968
- items.push({
969
- height: 1,
970
- feedLineIdx: -1,
971
- render: (cx, cy, cw, _ch) => {
972
- this._topSeparator.arrange({ x: cx, y: cy, width: cw, height: 1 });
973
- this._topSeparator.render(ctx);
974
- },
975
- });
1008
+ // Banner height changes during animation — always re-measure
1009
+ this._feed.invalidateItem("__banner__");
1010
+ items.push({ id: "__banner__", content: this._banner });
1011
+ items.push({ id: "__topsep__", content: this._topSeparator });
976
1012
  }
977
- // Feed lines — use cached heights to avoid re-measuring every line each frame.
978
- // Cache is invalidated when content width changes (e.g. terminal resize).
979
- if (contentWidth !== this._feedHeightCacheWidth) {
980
- this._feedHeightCache = [];
981
- this._feedHeightCacheWidth = contentWidth;
1013
+ this._feedItemOffset = items.length;
1014
+ for (const fi of this._store.items) {
1015
+ items.push(fi);
982
1016
  }
983
- for (let fi = 0; fi < this._feedLines.length; fi++) {
984
- const line = this._feedLines[fi];
985
- let h = this._feedHeightCache[fi];
986
- if (h === undefined) {
987
- const lineSize = line.measure({
988
- minWidth: 0,
989
- maxWidth: contentWidth,
990
- minHeight: 0,
991
- maxHeight: Infinity,
992
- });
993
- h = Math.max(1, lineSize.height);
994
- this._feedHeightCache[fi] = h;
995
- }
996
- items.push({
997
- height: h,
998
- feedLineIdx: fi,
999
- render: (cx, cy, cw, ch) => {
1000
- line.arrange({ x: cx, y: cy, width: cw, height: ch });
1001
- line.render(ctx);
1002
- },
1003
- });
1004
- }
1005
- // Calculate total content height
1006
- let totalContentH = 0;
1007
- for (const item of items) {
1008
- totalContentH += item.height;
1009
- }
1010
- // Clamp scroll offset
1011
- const maxScroll = Math.max(0, totalContentH - height);
1012
- this._feedScrollOffset = Math.max(0, Math.min(this._feedScrollOffset, maxScroll));
1013
- // Clip feed area
1014
- ctx.pushClip({ x, y, width, height });
1015
- // Find the first visible item
1016
- let skippedRows = 0;
1017
- let startIdx = 0;
1018
- for (let i = 0; i < items.length; i++) {
1019
- if (skippedRows + items[i].height > this._feedScrollOffset)
1020
- break;
1021
- skippedRows += items[i].height;
1022
- startIdx = i + 1;
1023
- }
1024
- // Render visible items and build screen→feedLine map
1025
- this._screenToFeedLine.clear();
1026
- this._screenToFeedRow.clear();
1027
- let cy = y - (this._feedScrollOffset - skippedRows);
1028
- for (let i = startIdx; i < items.length && cy < y + height; i++) {
1029
- const item = items[i];
1030
- item.render(x, cy, contentWidth, item.height);
1031
- // Map screen rows to feed line index + row offset for hit-testing
1032
- if (item.feedLineIdx >= 0) {
1033
- for (let row = 0; row < item.height; row++) {
1034
- const screenY = cy + row;
1035
- if (screenY >= y && screenY < y + height) {
1036
- this._screenToFeedLine.set(screenY, item.feedLineIdx);
1037
- this._screenToFeedRow.set(screenY, row);
1038
- }
1039
- }
1040
- }
1041
- cy += item.height;
1042
- }
1043
- // Always cache feed geometry for selection edge-detection
1044
- this._feedY = y;
1045
- this._feedH = height;
1046
- // Render scrollbar and cache geometry for hit-testing
1047
- if (height > 0 && totalContentH > height) {
1048
- const scrollX = x + width - 1;
1049
- const thumbSize = Math.max(1, Math.round((height / totalContentH) * height));
1050
- const thumbPos = maxScroll > 0
1051
- ? Math.round((this._feedScrollOffset / maxScroll) * (height - thumbSize))
1052
- : 0;
1053
- const trackStyle = this._separatorStyle;
1054
- const thumbStyle = this._feedStyle;
1055
- // Cache for mouse interaction
1056
- this._scrollbarX = scrollX;
1057
- this._thumbPos = thumbPos;
1058
- this._thumbSize = thumbSize;
1059
- this._maxScroll = maxScroll;
1060
- this._scrollbarVisible = true;
1061
- for (let row = 0; row < height; row++) {
1062
- const inThumb = row >= thumbPos && row < thumbPos + thumbSize;
1063
- ctx.drawChar(scrollX, y + row, inThumb ? "┃" : "│", inThumb ? thumbStyle : trackStyle);
1064
- }
1065
- }
1066
- else {
1067
- this._scrollbarVisible = false;
1068
- }
1069
- // Render selection highlight overlay
1070
- if (this._selAnchor && this._selEnd) {
1071
- this._renderSelection(ctx, x, y, width, height);
1072
- }
1073
- ctx.popClip();
1017
+ this._feed.items = items;
1074
1018
  }
1075
1019
  // ── Dropdown rendering ─────────────────────────────────────────
1076
1020
  _renderDropdown(ctx, x, y, width, height) {
@@ -1115,10 +1059,13 @@ export class ChatView extends Control {
1115
1059
  [startY, endY] = [endY, startY];
1116
1060
  [startX, endX] = [endX, startX];
1117
1061
  }
1062
+ const fb = this._feed.bounds;
1063
+ const feedX = fb?.x ?? 0;
1064
+ const contentW = this._feed.contentWidth;
1118
1065
  const lines = [];
1119
1066
  for (let row = startY; row <= endY; row++) {
1120
- const colStart = row === startY ? startX : this._feedX;
1121
- const colEnd = row === endY ? endX : this._feedX + this._contentWidth - 1;
1067
+ const colStart = row === startY ? startX : feedX;
1068
+ const colEnd = row === endY ? endX : feedX + contentW - 1;
1122
1069
  let line = "";
1123
1070
  for (let col = colStart; col <= colEnd; col++) {
1124
1071
  const ch = this._ctx.readCharAbsolute(col, row);
@@ -1170,11 +1117,12 @@ export class ChatView extends Control {
1170
1117
  this.scrollFeed(this._selScrollDir * 3);
1171
1118
  // Move selEnd to keep extending the selection while scrolling
1172
1119
  if (this._selEnd) {
1120
+ const fb = this._feed.bounds;
1121
+ const feedY = fb?.y ?? 0;
1122
+ const feedH = fb?.height ?? 0;
1173
1123
  this._selEnd = {
1174
1124
  x: this._selEnd.x,
1175
- y: this._selScrollDir < 0
1176
- ? this._feedY
1177
- : this._feedY + this._feedH - 1,
1125
+ y: this._selScrollDir < 0 ? feedY : feedY + feedH - 1,
1178
1126
  };
1179
1127
  }
1180
1128
  }, 80);
@@ -1188,8 +1136,7 @@ export class ChatView extends Control {
1188
1136
  this._selScrollDir = 0;
1189
1137
  }
1190
1138
  _autoScrollToBottom() {
1191
- // Set scroll to a very large value; it will be clamped during render
1192
- this._feedScrollOffset = Number.MAX_SAFE_INTEGER;
1139
+ this._feed.autoScrollToBottom();
1193
1140
  }
1194
1141
  }
1195
1142
  // ── Internal: Separator line control ─────────────────────────────
@@ -0,0 +1,58 @@
1
+ /**
2
+ * FeedStore — Identity-based feed item collection for ChatView.
3
+ *
4
+ * Replaces the parallel index-keyed data structures (_feedLines, _feedActions,
5
+ * _hiddenFeedLines) with a single array of FeedItem objects. Each item has a
6
+ * stable unique ID so external code can reference items without worrying about
7
+ * index shifts on insert/remove.
8
+ */
9
+ import type { StyledLine, StyledText } from "./styled-text.js";
10
+ /** A single clickable action within an action line. */
11
+ export interface FeedActionItem {
12
+ id: string;
13
+ normalStyle: StyledLine;
14
+ hoverStyle: StyledLine;
15
+ }
16
+ /** Entry attached to a feed item — single action or multiple side-by-side. */
17
+ export interface FeedActionEntry {
18
+ /** All action items on this line. */
19
+ items: FeedActionItem[];
20
+ /** Combined normal style for the full line. */
21
+ normalStyle: StyledLine;
22
+ }
23
+ /** A single item in the feed. */
24
+ export interface FeedItem {
25
+ /** Stable unique ID. Never changes after creation. */
26
+ readonly id: string;
27
+ /** The renderable content. */
28
+ content: StyledText;
29
+ /** Optional clickable actions attached to this item. */
30
+ actions?: FeedActionEntry;
31
+ /** Whether this item is currently hidden/collapsed. */
32
+ hidden?: boolean;
33
+ }
34
+ export declare class FeedStore {
35
+ private _items;
36
+ private _byId;
37
+ private _nextId;
38
+ /** Generate a stable unique ID. */
39
+ createId(): string;
40
+ /** Append an item to the end. */
41
+ push(content: StyledText, actions?: FeedActionEntry): FeedItem;
42
+ /** Insert an item at position. Existing items shift — no external bookkeeping needed. */
43
+ insert(index: number, content: StyledText, actions?: FeedActionEntry): FeedItem;
44
+ /** Get item by ID (O(1)). */
45
+ get(id: string): FeedItem | undefined;
46
+ /** Get item by position index (for rendering). */
47
+ at(index: number): FeedItem | undefined;
48
+ /** Find the current index of an item by ID. Returns -1 if not found. */
49
+ indexOf(id: string): number;
50
+ /** Number of items. */
51
+ get length(): number;
52
+ /** Read-only access to the items array (for iteration in render loops). */
53
+ get items(): readonly FeedItem[];
54
+ /** Remove all items. */
55
+ clear(): void;
56
+ /** Check if any item has actions. */
57
+ get hasActions(): boolean;
58
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * FeedStore — Identity-based feed item collection for ChatView.
3
+ *
4
+ * Replaces the parallel index-keyed data structures (_feedLines, _feedActions,
5
+ * _hiddenFeedLines) with a single array of FeedItem objects. Each item has a
6
+ * stable unique ID so external code can reference items without worrying about
7
+ * index shifts on insert/remove.
8
+ */
9
+ // ── FeedStore ──────────────────────────────────────────────────────
10
+ export class FeedStore {
11
+ _items = [];
12
+ _byId = new Map();
13
+ _nextId = 0;
14
+ /** Generate a stable unique ID. */
15
+ createId() {
16
+ return `f${this._nextId++}`;
17
+ }
18
+ /** Append an item to the end. */
19
+ push(content, actions) {
20
+ const item = { id: this.createId(), content, actions };
21
+ this._items.push(item);
22
+ this._byId.set(item.id, item);
23
+ return item;
24
+ }
25
+ /** Insert an item at position. Existing items shift — no external bookkeeping needed. */
26
+ insert(index, content, actions) {
27
+ const clamped = Math.max(0, Math.min(index, this._items.length));
28
+ const item = { id: this.createId(), content, actions };
29
+ this._items.splice(clamped, 0, item);
30
+ this._byId.set(item.id, item);
31
+ return item;
32
+ }
33
+ /** Get item by ID (O(1)). */
34
+ get(id) {
35
+ return this._byId.get(id);
36
+ }
37
+ /** Get item by position index (for rendering). */
38
+ at(index) {
39
+ return this._items[index];
40
+ }
41
+ /** Find the current index of an item by ID. Returns -1 if not found. */
42
+ indexOf(id) {
43
+ const item = this._byId.get(id);
44
+ if (!item)
45
+ return -1;
46
+ return this._items.indexOf(item);
47
+ }
48
+ /** Number of items. */
49
+ get length() {
50
+ return this._items.length;
51
+ }
52
+ /** Read-only access to the items array (for iteration in render loops). */
53
+ get items() {
54
+ return this._items;
55
+ }
56
+ /** Remove all items. */
57
+ clear() {
58
+ this._items = [];
59
+ this._byId.clear();
60
+ }
61
+ /** Check if any item has actions. */
62
+ get hasActions() {
63
+ for (const item of this._items) {
64
+ if (item.actions)
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * VirtualList — Reusable scrollable widget for terminal UIs.
3
+ *
4
+ * Handles virtual scrolling, height caching (by item ID), screen-to-item
5
+ * mapping for hit-testing, and scrollbar rendering. Extracted from ChatView
6
+ * as part of the widget model redesign (Phase 2).
7
+ */
8
+ import type { DrawingContext, TextStyle } from "../drawing/context.js";
9
+ import { Control } from "../layout/control.js";
10
+ import type { Constraint, Rect, Size } from "../layout/types.js";
11
+ /** An item renderable by VirtualList. */
12
+ export interface VirtualListItem {
13
+ /** Stable unique ID (used for height cache keying). */
14
+ readonly id: string;
15
+ /** The renderable content — must support measure/arrange/render. */
16
+ readonly content: {
17
+ measure(constraint: Constraint): Size;
18
+ arrange(rect: Rect): void;
19
+ render(ctx: DrawingContext): void;
20
+ };
21
+ /** Whether this item is currently hidden (takes zero height). */
22
+ hidden?: boolean;
23
+ }
24
+ export interface VirtualListOptions {
25
+ /** Style for the scrollbar track. */
26
+ trackStyle?: TextStyle;
27
+ /** Style for the scrollbar thumb. */
28
+ thumbStyle?: TextStyle;
29
+ }
30
+ export declare class VirtualList extends Control {
31
+ private _items;
32
+ private _scrollOffset;
33
+ private _userScrolledAway;
34
+ private _maxScroll;
35
+ private _heightCache;
36
+ private _cacheWidth;
37
+ private _screenToItemIdx;
38
+ private _screenToRow;
39
+ private _scrollbarX;
40
+ private _scrollbarVisible;
41
+ private _thumbPos;
42
+ private _thumbSize;
43
+ private _dragging;
44
+ private _dragOffsetY;
45
+ private _trackStyle;
46
+ private _thumbStyle;
47
+ private _contentWidth;
48
+ /** Called after items render but before clip pops. For selection overlay etc. */
49
+ onRenderOverlay?: (ctx: DrawingContext, x: number, y: number, width: number, height: number) => void;
50
+ constructor(options?: VirtualListOptions);
51
+ set items(items: VirtualListItem[]);
52
+ get items(): VirtualListItem[];
53
+ /** Scroll by delta rows (positive = down, negative = up). */
54
+ scroll(delta: number): void;
55
+ /** Scroll to the very bottom. Resets the "scrolled away" flag. */
56
+ scrollToBottom(): void;
57
+ /** Auto-scroll to bottom if the user hasn't scrolled away. */
58
+ autoScrollToBottom(): void;
59
+ /** Whether the user has scrolled away from the bottom. */
60
+ get isScrolledAway(): boolean;
61
+ /** Invalidate cached height for a single item (e.g. after content change). */
62
+ invalidateItem(id: string): void;
63
+ /** Invalidate all cached heights. */
64
+ invalidateAllHeights(): void;
65
+ /** Reset all state (scroll, cache, maps). Used when feed is cleared. */
66
+ reset(): void;
67
+ /** Get the item index (in the items array) at a screen Y coordinate. Returns -1 if none. */
68
+ itemIndexAtScreen(screenY: number): number;
69
+ /** Get the row offset within the item at a screen Y coordinate. */
70
+ rowAtScreen(screenY: number): number;
71
+ get scrollbarVisible(): boolean;
72
+ get scrollbarX(): number;
73
+ get maxScroll(): number;
74
+ get contentWidth(): number;
75
+ get isDragging(): boolean;
76
+ /** Handle a mouse press on the scrollbar. */
77
+ handleScrollbarPress(screenY: number): void;
78
+ /** Handle a mouse drag on the scrollbar. */
79
+ handleScrollbarDrag(screenY: number): void;
80
+ /** Handle mouse release (end scrollbar drag). */
81
+ handleScrollbarRelease(): void;
82
+ measure(constraint: Constraint): Size;
83
+ arrange(rect: Rect): void;
84
+ render(ctx: DrawingContext): void;
85
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * VirtualList — Reusable scrollable widget for terminal UIs.
3
+ *
4
+ * Handles virtual scrolling, height caching (by item ID), screen-to-item
5
+ * mapping for hit-testing, and scrollbar rendering. Extracted from ChatView
6
+ * as part of the widget model redesign (Phase 2).
7
+ */
8
+ import { Control } from "../layout/control.js";
9
+ // ── VirtualList ────────────────────────────────────────────────────
10
+ export class VirtualList extends Control {
11
+ _items = [];
12
+ // ── Scroll state ──────────────────────────────────────────────
13
+ _scrollOffset = 0;
14
+ _userScrolledAway = false;
15
+ _maxScroll = 0;
16
+ // ── Height cache (keyed by item ID, cleared on width change) ──
17
+ _heightCache = new Map();
18
+ _cacheWidth = -1;
19
+ // ── Screen mapping (rebuilt each render) ──────────────────────
20
+ _screenToItemIdx = new Map();
21
+ _screenToRow = new Map();
22
+ // ── Scrollbar state ───────────────────────────────────────────
23
+ _scrollbarX = -1;
24
+ _scrollbarVisible = false;
25
+ _thumbPos = 0;
26
+ _thumbSize = 0;
27
+ _dragging = false;
28
+ _dragOffsetY = 0;
29
+ // ── Styles ────────────────────────────────────────────────────
30
+ _trackStyle;
31
+ _thumbStyle;
32
+ // ── Content geometry (set during render, used by callers) ─────
33
+ _contentWidth = 0;
34
+ /** Called after items render but before clip pops. For selection overlay etc. */
35
+ onRenderOverlay;
36
+ constructor(options = {}) {
37
+ super();
38
+ this._trackStyle = options.trackStyle ?? {};
39
+ this._thumbStyle = options.thumbStyle ?? {};
40
+ }
41
+ // ── Public: Items ─────────────────────────────────────────────
42
+ set items(items) {
43
+ this._items = items;
44
+ }
45
+ get items() {
46
+ return this._items;
47
+ }
48
+ // ── Public: Scroll ────────────────────────────────────────────
49
+ /** Scroll by delta rows (positive = down, negative = up). */
50
+ scroll(delta) {
51
+ this._scrollOffset = Math.max(0, this._scrollOffset + delta);
52
+ this._userScrolledAway = this._scrollOffset < this._maxScroll;
53
+ }
54
+ /** Scroll to the very bottom. Resets the "scrolled away" flag. */
55
+ scrollToBottom() {
56
+ this._userScrolledAway = false;
57
+ this._scrollOffset = Number.MAX_SAFE_INTEGER;
58
+ }
59
+ /** Auto-scroll to bottom if the user hasn't scrolled away. */
60
+ autoScrollToBottom() {
61
+ if (this._userScrolledAway)
62
+ return;
63
+ this._scrollOffset = Number.MAX_SAFE_INTEGER;
64
+ }
65
+ /** Whether the user has scrolled away from the bottom. */
66
+ get isScrolledAway() {
67
+ return this._userScrolledAway;
68
+ }
69
+ // ── Public: Height cache ──────────────────────────────────────
70
+ /** Invalidate cached height for a single item (e.g. after content change). */
71
+ invalidateItem(id) {
72
+ this._heightCache.delete(id);
73
+ }
74
+ /** Invalidate all cached heights. */
75
+ invalidateAllHeights() {
76
+ this._heightCache.clear();
77
+ this._cacheWidth = -1;
78
+ }
79
+ /** Reset all state (scroll, cache, maps). Used when feed is cleared. */
80
+ reset() {
81
+ this._scrollOffset = 0;
82
+ this._userScrolledAway = false;
83
+ this._maxScroll = 0;
84
+ this._heightCache.clear();
85
+ this._cacheWidth = -1;
86
+ this._screenToItemIdx.clear();
87
+ this._screenToRow.clear();
88
+ this._scrollbarVisible = false;
89
+ }
90
+ // ── Public: Hit-testing ───────────────────────────────────────
91
+ /** Get the item index (in the items array) at a screen Y coordinate. Returns -1 if none. */
92
+ itemIndexAtScreen(screenY) {
93
+ return this._screenToItemIdx.get(screenY) ?? -1;
94
+ }
95
+ /** Get the row offset within the item at a screen Y coordinate. */
96
+ rowAtScreen(screenY) {
97
+ return this._screenToRow.get(screenY) ?? 0;
98
+ }
99
+ // ── Public: Scrollbar geometry ────────────────────────────────
100
+ get scrollbarVisible() {
101
+ return this._scrollbarVisible;
102
+ }
103
+ get scrollbarX() {
104
+ return this._scrollbarX;
105
+ }
106
+ get maxScroll() {
107
+ return this._maxScroll;
108
+ }
109
+ get contentWidth() {
110
+ return this._contentWidth;
111
+ }
112
+ get isDragging() {
113
+ return this._dragging;
114
+ }
115
+ // ── Public: Scrollbar interaction ─────────────────────────────
116
+ /** Handle a mouse press on the scrollbar. */
117
+ handleScrollbarPress(screenY) {
118
+ const b = this.bounds;
119
+ if (!b)
120
+ return;
121
+ const relY = screenY - b.y;
122
+ if (relY >= this._thumbPos && relY < this._thumbPos + this._thumbSize) {
123
+ this._dragging = true;
124
+ this._dragOffsetY = relY - this._thumbPos;
125
+ }
126
+ else {
127
+ // Click-to-position
128
+ const ratio = relY / b.height;
129
+ this._scrollOffset = Math.round(ratio * this._maxScroll);
130
+ this._scrollOffset = Math.max(0, Math.min(this._scrollOffset, this._maxScroll));
131
+ this._userScrolledAway = this._scrollOffset < this._maxScroll;
132
+ }
133
+ }
134
+ /** Handle a mouse drag on the scrollbar. */
135
+ handleScrollbarDrag(screenY) {
136
+ const b = this.bounds;
137
+ if (!b || !this._dragging)
138
+ return;
139
+ const relY = screenY - b.y;
140
+ const newThumbPos = relY - this._dragOffsetY;
141
+ const maxThumbPos = b.height - this._thumbSize;
142
+ const clampedPos = Math.max(0, Math.min(newThumbPos, maxThumbPos));
143
+ const ratio = maxThumbPos > 0 ? clampedPos / maxThumbPos : 0;
144
+ this._scrollOffset = Math.round(ratio * this._maxScroll);
145
+ this._userScrolledAway = this._scrollOffset < this._maxScroll;
146
+ }
147
+ /** Handle mouse release (end scrollbar drag). */
148
+ handleScrollbarRelease() {
149
+ this._dragging = false;
150
+ }
151
+ // ── Control overrides ─────────────────────────────────────────
152
+ measure(constraint) {
153
+ const size = {
154
+ width: constraint.maxWidth,
155
+ height: constraint.maxHeight,
156
+ };
157
+ this.desiredSize = size;
158
+ return size;
159
+ }
160
+ arrange(rect) {
161
+ this.bounds = rect;
162
+ }
163
+ render(ctx) {
164
+ const b = this.bounds;
165
+ if (!b || b.width < 1 || b.height < 1)
166
+ return;
167
+ const width = b.width;
168
+ const height = b.height;
169
+ const contentWidth = width - 1; // reserve 1 col for scrollbar
170
+ this._contentWidth = contentWidth;
171
+ // Invalidate height cache on width change
172
+ if (contentWidth !== this._cacheWidth) {
173
+ this._heightCache.clear();
174
+ this._cacheWidth = contentWidth;
175
+ }
176
+ // Build measured visible items
177
+ const indices = [];
178
+ const heights = [];
179
+ for (let i = 0; i < this._items.length; i++) {
180
+ const item = this._items[i];
181
+ if (item.hidden)
182
+ continue;
183
+ let h = this._heightCache.get(item.id);
184
+ if (h === undefined) {
185
+ const size = item.content.measure({
186
+ minWidth: 0,
187
+ maxWidth: contentWidth,
188
+ minHeight: 0,
189
+ maxHeight: Infinity,
190
+ });
191
+ h = Math.max(1, size.height);
192
+ this._heightCache.set(item.id, h);
193
+ }
194
+ indices.push(i);
195
+ heights.push(h);
196
+ }
197
+ // Total content height
198
+ let totalContentH = 0;
199
+ for (const h of heights) {
200
+ totalContentH += h;
201
+ }
202
+ // Clamp scroll offset
203
+ const maxScroll = Math.max(0, totalContentH - height);
204
+ this._scrollOffset = Math.max(0, Math.min(this._scrollOffset, maxScroll));
205
+ this._maxScroll = maxScroll;
206
+ // Clip to our bounds
207
+ ctx.pushClip({ x: b.x, y: b.y, width, height });
208
+ // Find first visible item
209
+ let skippedRows = 0;
210
+ let startIdx = 0;
211
+ for (let i = 0; i < indices.length; i++) {
212
+ if (skippedRows + heights[i] > this._scrollOffset)
213
+ break;
214
+ skippedRows += heights[i];
215
+ startIdx = i + 1;
216
+ }
217
+ // Render visible items and build screen→item maps
218
+ this._screenToItemIdx.clear();
219
+ this._screenToRow.clear();
220
+ let cy = b.y - (this._scrollOffset - skippedRows);
221
+ for (let i = startIdx; i < indices.length && cy < b.y + height; i++) {
222
+ const itemIdx = indices[i];
223
+ const item = this._items[itemIdx];
224
+ const h = heights[i];
225
+ item.content.arrange({ x: b.x, y: cy, width: contentWidth, height: h });
226
+ item.content.render(ctx);
227
+ // Map screen rows to item index + row offset
228
+ for (let row = 0; row < h; row++) {
229
+ const screenY = cy + row;
230
+ if (screenY >= b.y && screenY < b.y + height) {
231
+ this._screenToItemIdx.set(screenY, itemIdx);
232
+ this._screenToRow.set(screenY, row);
233
+ }
234
+ }
235
+ cy += h;
236
+ }
237
+ // Render scrollbar
238
+ if (height > 0 && totalContentH > height) {
239
+ const scrollX = b.x + width - 1;
240
+ const thumbSize = Math.max(1, Math.round((height / totalContentH) * height));
241
+ const thumbPos = maxScroll > 0
242
+ ? Math.round((this._scrollOffset / maxScroll) * (height - thumbSize))
243
+ : 0;
244
+ this._scrollbarX = scrollX;
245
+ this._thumbPos = thumbPos;
246
+ this._thumbSize = thumbSize;
247
+ this._scrollbarVisible = true;
248
+ for (let row = 0; row < height; row++) {
249
+ const inThumb = row >= thumbPos && row < thumbPos + thumbSize;
250
+ ctx.drawChar(scrollX, b.y + row, inThumb ? "┃" : "│", inThumb ? this._thumbStyle : this._trackStyle);
251
+ }
252
+ }
253
+ else {
254
+ this._scrollbarVisible = false;
255
+ }
256
+ // Overlay callback (selection, etc.)
257
+ if (this.onRenderOverlay) {
258
+ this.onRenderOverlay(ctx, b.x, b.y, width, height);
259
+ }
260
+ ctx.popClip();
261
+ }
262
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teammates/consolonia",
3
- "version": "0.6.2",
3
+ "version": "0.7.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",