@teammates/consolonia 0.6.3 → 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 +3 -1
- package/dist/index.js +2 -0
- package/dist/widgets/chat-view.d.ts +28 -35
- package/dist/widgets/chat-view.js +198 -251
- package/dist/widgets/feed-store.d.ts +58 -0
- package/dist/widgets/feed-store.js +69 -0
- package/dist/widgets/virtual-list.d.ts +85 -0
- package/dist/widgets/virtual-list.js +262 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
240
|
+
const content = new StyledText({
|
|
251
241
|
lines: [text],
|
|
252
242
|
defaultStyle: style ?? this._feedStyle,
|
|
253
243
|
wrap: true,
|
|
254
244
|
});
|
|
255
|
-
this.
|
|
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
|
|
251
|
+
const content = new StyledText({
|
|
262
252
|
lines: [styledLine],
|
|
263
253
|
defaultStyle: this._feedStyle,
|
|
264
254
|
wrap: true,
|
|
265
255
|
});
|
|
266
|
-
this.
|
|
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
|
|
262
|
+
const content = new StyledText({
|
|
273
263
|
lines: [normalContent],
|
|
274
264
|
defaultStyle: this._feedStyle,
|
|
275
265
|
wrap: false,
|
|
276
266
|
});
|
|
277
|
-
|
|
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
|
|
279
|
+
const content = new StyledText({
|
|
292
280
|
lines: [combined],
|
|
293
281
|
defaultStyle: this._feedStyle,
|
|
294
282
|
wrap: false,
|
|
295
283
|
});
|
|
296
|
-
|
|
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
|
|
302
|
+
const content = new StyledText({
|
|
317
303
|
lines: [text],
|
|
318
304
|
defaultStyle: style ?? this._feedStyle,
|
|
319
305
|
wrap: true,
|
|
320
306
|
});
|
|
321
|
-
this.
|
|
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.
|
|
329
|
-
this.
|
|
330
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
561
|
-
|
|
562
|
-
me.
|
|
563
|
-
me.y
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
568
|
-
if (
|
|
569
|
-
|
|
570
|
-
|
|
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.
|
|
584
|
-
|
|
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.
|
|
596
|
-
this.
|
|
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.
|
|
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.
|
|
628
|
-
const col = me.x -
|
|
629
|
-
const charOffset = row * this.
|
|
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.
|
|
643
|
-
const isAction = feedLineIdx >= 0 && this.
|
|
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 =
|
|
656
|
-
const feedBot =
|
|
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.
|
|
687
|
-
const feedLineIdx = this.
|
|
688
|
-
const
|
|
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
|
|
691
|
-
if (
|
|
739
|
+
const newHoverId = entry ? feedItem.id : null;
|
|
740
|
+
if (newHoverId !== this._hoveredItemId ||
|
|
692
741
|
(entry && entry.items.length > 1)) {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
754
|
+
feedItem.content.lines = [hoverLine];
|
|
755
|
+
this._feed.invalidateItem(feedItem.id);
|
|
705
756
|
}
|
|
706
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
|
940
|
+
// 1. Feed area — delegate to VirtualList
|
|
880
941
|
if (feedH > 0) {
|
|
881
|
-
this.
|
|
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
|
-
|
|
943
|
-
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
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 :
|
|
1121
|
-
const colEnd = row === endY ? endX :
|
|
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
|
-
|
|
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