@teammates/consolonia 0.2.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/README.md +48 -0
- package/dist/__tests__/ansi.test.d.ts +1 -0
- package/dist/__tests__/ansi.test.js +520 -0
- package/dist/__tests__/chat-view.test.d.ts +4 -0
- package/dist/__tests__/chat-view.test.js +480 -0
- package/dist/__tests__/drawing.test.d.ts +4 -0
- package/dist/__tests__/drawing.test.js +426 -0
- package/dist/__tests__/input.test.d.ts +5 -0
- package/dist/__tests__/input.test.js +911 -0
- package/dist/__tests__/layout.test.d.ts +4 -0
- package/dist/__tests__/layout.test.js +689 -0
- package/dist/__tests__/pixel.test.d.ts +1 -0
- package/dist/__tests__/pixel.test.js +674 -0
- package/dist/__tests__/render.test.d.ts +1 -0
- package/dist/__tests__/render.test.js +400 -0
- package/dist/__tests__/styled.test.d.ts +4 -0
- package/dist/__tests__/styled.test.js +149 -0
- package/dist/__tests__/widgets.test.d.ts +5 -0
- package/dist/__tests__/widgets.test.js +924 -0
- package/dist/ansi/esc.d.ts +61 -0
- package/dist/ansi/esc.js +85 -0
- package/dist/ansi/output.d.ts +66 -0
- package/dist/ansi/output.js +192 -0
- package/dist/ansi/strip.d.ts +16 -0
- package/dist/ansi/strip.js +74 -0
- package/dist/app.d.ts +68 -0
- package/dist/app.js +297 -0
- package/dist/drawing/clip.d.ts +23 -0
- package/dist/drawing/clip.js +67 -0
- package/dist/drawing/context.d.ts +77 -0
- package/dist/drawing/context.js +275 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +63 -0
- package/dist/input/escape-matcher.d.ts +27 -0
- package/dist/input/escape-matcher.js +253 -0
- package/dist/input/events.d.ts +49 -0
- package/dist/input/events.js +17 -0
- package/dist/input/index.d.ts +15 -0
- package/dist/input/index.js +14 -0
- package/dist/input/matcher.d.ts +23 -0
- package/dist/input/matcher.js +14 -0
- package/dist/input/mouse-matcher.d.ts +27 -0
- package/dist/input/mouse-matcher.js +142 -0
- package/dist/input/paste-matcher.d.ts +23 -0
- package/dist/input/paste-matcher.js +104 -0
- package/dist/input/processor.d.ts +51 -0
- package/dist/input/processor.js +145 -0
- package/dist/input/raw-mode.d.ts +13 -0
- package/dist/input/raw-mode.js +24 -0
- package/dist/input/text-matcher.d.ts +14 -0
- package/dist/input/text-matcher.js +32 -0
- package/dist/layout/box.d.ts +33 -0
- package/dist/layout/box.js +92 -0
- package/dist/layout/column.d.ts +21 -0
- package/dist/layout/column.js +90 -0
- package/dist/layout/control.d.ts +73 -0
- package/dist/layout/control.js +215 -0
- package/dist/layout/row.d.ts +21 -0
- package/dist/layout/row.js +95 -0
- package/dist/layout/stack.d.ts +18 -0
- package/dist/layout/stack.js +64 -0
- package/dist/layout/types.d.ts +27 -0
- package/dist/layout/types.js +4 -0
- package/dist/pixel/background.d.ts +16 -0
- package/dist/pixel/background.js +16 -0
- package/dist/pixel/box-pattern.d.ts +38 -0
- package/dist/pixel/box-pattern.js +57 -0
- package/dist/pixel/buffer.d.ts +25 -0
- package/dist/pixel/buffer.js +51 -0
- package/dist/pixel/color.d.ts +48 -0
- package/dist/pixel/color.js +92 -0
- package/dist/pixel/foreground.d.ts +31 -0
- package/dist/pixel/foreground.js +64 -0
- package/dist/pixel/pixel.d.ts +21 -0
- package/dist/pixel/pixel.js +38 -0
- package/dist/pixel/symbol.d.ts +38 -0
- package/dist/pixel/symbol.js +192 -0
- package/dist/render/regions.d.ts +54 -0
- package/dist/render/regions.js +102 -0
- package/dist/render/render-target.d.ts +42 -0
- package/dist/render/render-target.js +118 -0
- package/dist/styled.d.ts +113 -0
- package/dist/styled.js +176 -0
- package/dist/widgets/border.d.ts +34 -0
- package/dist/widgets/border.js +121 -0
- package/dist/widgets/chat-view.d.ts +239 -0
- package/dist/widgets/chat-view.js +993 -0
- package/dist/widgets/interview.d.ts +87 -0
- package/dist/widgets/interview.js +187 -0
- package/dist/widgets/markdown.d.ts +87 -0
- package/dist/widgets/markdown.js +611 -0
- package/dist/widgets/panel.d.ts +19 -0
- package/dist/widgets/panel.js +35 -0
- package/dist/widgets/scroll-view.d.ts +43 -0
- package/dist/widgets/scroll-view.js +182 -0
- package/dist/widgets/styled-text.d.ts +38 -0
- package/dist/widgets/styled-text.js +183 -0
- package/dist/widgets/syntax.d.ts +37 -0
- package/dist/widgets/syntax.js +670 -0
- package/dist/widgets/text-input.d.ts +121 -0
- package/dist/widgets/text-input.js +618 -0
- package/dist/widgets/text.d.ts +34 -0
- package/dist/widgets/text.js +168 -0
- package/package.json +45 -0
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatView — full-screen chat widget for terminal REPLs.
|
|
3
|
+
*
|
|
4
|
+
* Layout (top to bottom):
|
|
5
|
+
*
|
|
6
|
+
* ┌─ banner ──────────────────────────────┐
|
|
7
|
+
* │ customizable multi-line header text │
|
|
8
|
+
* ├───────────────────────────────────────-┤
|
|
9
|
+
* │ │
|
|
10
|
+
* │ scrollable feed area │
|
|
11
|
+
* │ (messages, agent output, etc.) │
|
|
12
|
+
* │ │
|
|
13
|
+
* ├───────────────────────────────────────-┤
|
|
14
|
+
* │ progress message (optional) │
|
|
15
|
+
* │ ❯ input box │
|
|
16
|
+
* │ ┌─ dropdown ───────────────────────┐ │
|
|
17
|
+
* │ │ /command1 description │ │
|
|
18
|
+
* │ │ /command2 description │ │
|
|
19
|
+
* │ └──────────────────────────────────-┘ │
|
|
20
|
+
* └────────────────────────────────────────┘
|
|
21
|
+
*
|
|
22
|
+
* The feed is the terminal's own scrollback: new content is appended
|
|
23
|
+
* as Text children to the feed Column. Everything is double-buffered
|
|
24
|
+
* through Consolonia's PixelBuffer so resizing redraws cleanly.
|
|
25
|
+
*
|
|
26
|
+
* Events emitted:
|
|
27
|
+
* "submit" (text: string) — user pressed Enter
|
|
28
|
+
* "change" (text: string) — input value changed
|
|
29
|
+
* "cancel" () — user pressed Escape
|
|
30
|
+
* "tab" () — user pressed Tab (for autocomplete)
|
|
31
|
+
*/
|
|
32
|
+
import { Control } from "../layout/control.js";
|
|
33
|
+
import { StyledText } from "./styled-text.js";
|
|
34
|
+
import { Text } from "./text.js";
|
|
35
|
+
import { TextInput, } from "./text-input.js";
|
|
36
|
+
// ── URL detection ──────────────────────────────────────────────────
|
|
37
|
+
const URL_REGEX = /https?:\/\/[^\s)>\]]+/g;
|
|
38
|
+
// ── ChatView ───────────────────────────────────────────────────────
|
|
39
|
+
export class ChatView extends Control {
|
|
40
|
+
// ── Child controls ─────────────────────────────────────────────
|
|
41
|
+
_banner;
|
|
42
|
+
_topSeparator;
|
|
43
|
+
_feedLines = [];
|
|
44
|
+
/** Maps feed line index → action(s) for clickable lines. */
|
|
45
|
+
_feedActions = new Map();
|
|
46
|
+
/** Feed line index currently hovered (-1 if none). */
|
|
47
|
+
_hoveredAction = -1;
|
|
48
|
+
/** Maps screen Y → feed line index (rebuilt each render). */
|
|
49
|
+
_screenToFeedLine = new Map();
|
|
50
|
+
/** Maps screen Y → row offset within the feed line (for multi-row wrapped lines). */
|
|
51
|
+
_screenToFeedRow = new Map();
|
|
52
|
+
_bottomSeparator;
|
|
53
|
+
_progressText;
|
|
54
|
+
_input;
|
|
55
|
+
_inputSeparator;
|
|
56
|
+
_footer;
|
|
57
|
+
_dropdownItems = [];
|
|
58
|
+
_dropdownIndex = -1;
|
|
59
|
+
// ── Configuration ──────────────────────────────────────────────
|
|
60
|
+
_feedStyle;
|
|
61
|
+
_progressStyle;
|
|
62
|
+
_separatorStyle;
|
|
63
|
+
_separatorChar;
|
|
64
|
+
_dropdownHighlightStyle;
|
|
65
|
+
_dropdownStyle;
|
|
66
|
+
_footerStyle;
|
|
67
|
+
_maxInputH;
|
|
68
|
+
_feedScrollOffset = 0;
|
|
69
|
+
// ── Feed geometry (cached from last render for hit-testing) ──
|
|
70
|
+
_feedX = 0;
|
|
71
|
+
_contentWidth = 0;
|
|
72
|
+
// ── Scrollbar state ───────────────────────────────────────────
|
|
73
|
+
/** Cached from last render for hit-testing. */
|
|
74
|
+
_scrollbarX = -1;
|
|
75
|
+
_feedY = 0;
|
|
76
|
+
_feedH = 0;
|
|
77
|
+
_thumbPos = 0;
|
|
78
|
+
_thumbSize = 0;
|
|
79
|
+
_maxScroll = 0;
|
|
80
|
+
_scrollbarVisible = false;
|
|
81
|
+
/** True while the user is dragging the scrollbar thumb. */
|
|
82
|
+
_dragging = false;
|
|
83
|
+
/** The Y offset within the thumb where the drag started. */
|
|
84
|
+
_dragOffsetY = 0;
|
|
85
|
+
/** Optional widget that replaces the input area (e.g. Interview). */
|
|
86
|
+
_inputOverride = null;
|
|
87
|
+
constructor(options = {}) {
|
|
88
|
+
super();
|
|
89
|
+
this._feedStyle = options.feedStyle ?? {};
|
|
90
|
+
this._progressStyle = options.progressStyle ?? { italic: true };
|
|
91
|
+
this._separatorStyle = options.separatorStyle ?? {};
|
|
92
|
+
this._separatorChar = options.separatorChar ?? "─";
|
|
93
|
+
this._dropdownHighlightStyle = options.dropdownHighlightStyle ?? {
|
|
94
|
+
bold: true,
|
|
95
|
+
};
|
|
96
|
+
this._dropdownStyle = options.dropdownStyle ?? {};
|
|
97
|
+
this._footerStyle = options.footerStyle ?? {};
|
|
98
|
+
this._maxInputH = options.maxInputHeight ?? 1;
|
|
99
|
+
// Banner — use custom widget if provided, otherwise fall back to Text
|
|
100
|
+
if (options.bannerWidget) {
|
|
101
|
+
this._banner = options.bannerWidget;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
this._banner = new Text({
|
|
105
|
+
text: options.banner ?? "",
|
|
106
|
+
style: options.bannerStyle ?? {},
|
|
107
|
+
wrap: true,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
this.addChild(this._banner);
|
|
111
|
+
// Top separator (between banner and feed)
|
|
112
|
+
this._topSeparator = new _Separator(this._separatorChar, this._separatorStyle);
|
|
113
|
+
this.addChild(this._topSeparator);
|
|
114
|
+
// Bottom separator (between feed and input area)
|
|
115
|
+
this._bottomSeparator = new _Separator(this._separatorChar, this._separatorStyle);
|
|
116
|
+
this.addChild(this._bottomSeparator);
|
|
117
|
+
// Progress text (above separator, fixed)
|
|
118
|
+
this._progressText = new StyledText({
|
|
119
|
+
lines: [],
|
|
120
|
+
defaultStyle: this._progressStyle,
|
|
121
|
+
wrap: false,
|
|
122
|
+
});
|
|
123
|
+
this._progressText.visible = false;
|
|
124
|
+
this.addChild(this._progressText);
|
|
125
|
+
// Input
|
|
126
|
+
this._input = new TextInput({
|
|
127
|
+
prompt: options.prompt ?? "❯ ",
|
|
128
|
+
promptStyle: options.promptStyle ?? {},
|
|
129
|
+
style: options.inputStyle ?? {},
|
|
130
|
+
cursorStyle: options.cursorStyle ?? {},
|
|
131
|
+
placeholder: options.placeholder ?? "",
|
|
132
|
+
placeholderStyle: options.placeholderStyle ?? { italic: true },
|
|
133
|
+
history: options.history,
|
|
134
|
+
colorize: options.inputColorize,
|
|
135
|
+
deleteSize: options.inputDeleteSize,
|
|
136
|
+
hint: options.inputHint,
|
|
137
|
+
hintStyle: options.inputHintStyle,
|
|
138
|
+
});
|
|
139
|
+
this._input.focusable = true;
|
|
140
|
+
this._input.onFocus();
|
|
141
|
+
this.addChild(this._input);
|
|
142
|
+
// Separator between input and footer
|
|
143
|
+
this._inputSeparator = new _Separator(this._separatorChar, this._separatorStyle);
|
|
144
|
+
this.addChild(this._inputSeparator);
|
|
145
|
+
// Footer (below input separator / dropdown, always 1 row)
|
|
146
|
+
const footerLine = options.footer ?? "";
|
|
147
|
+
this._footer = new StyledText({
|
|
148
|
+
lines: [footerLine],
|
|
149
|
+
defaultStyle: this._footerStyle,
|
|
150
|
+
wrap: false,
|
|
151
|
+
});
|
|
152
|
+
this.addChild(this._footer);
|
|
153
|
+
// Wire input events to ChatView events
|
|
154
|
+
this._input.on("submit", (text) => this.emit("submit", text));
|
|
155
|
+
this._input.on("change", (text) => this.emit("change", text));
|
|
156
|
+
this._input.on("paste", (text) => this.emit("paste", text));
|
|
157
|
+
this._input.on("cancel", () => {
|
|
158
|
+
if (this._dropdownItems.length > 0) {
|
|
159
|
+
this.hideDropdown();
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
this.emit("cancel");
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
this._input.on("tab", () => {
|
|
166
|
+
if (this._dropdownItems.length > 0 && this._dropdownIndex >= 0) {
|
|
167
|
+
this.acceptDropdownItem();
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
this.emit("tab");
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// ── Public API: Banner ─────────────────────────────────────────
|
|
175
|
+
/** Get the banner text (only works when using the built-in Text banner). */
|
|
176
|
+
get banner() {
|
|
177
|
+
return this._banner instanceof Text ? this._banner.text : "";
|
|
178
|
+
}
|
|
179
|
+
/** Set the banner text (only works when using the built-in Text banner). */
|
|
180
|
+
set banner(text) {
|
|
181
|
+
if (this._banner instanceof Text) {
|
|
182
|
+
this._banner.text = text;
|
|
183
|
+
this._banner.visible = text.length > 0;
|
|
184
|
+
this.invalidate();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Get the banner style (only works when using the built-in Text banner). */
|
|
188
|
+
get bannerStyle() {
|
|
189
|
+
return this._banner instanceof Text ? this._banner.style : {};
|
|
190
|
+
}
|
|
191
|
+
/** Set the banner style (only works when using the built-in Text banner). */
|
|
192
|
+
set bannerStyle(style) {
|
|
193
|
+
if (this._banner instanceof Text) {
|
|
194
|
+
this._banner.style = style;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Replace the banner with a custom widget. */
|
|
198
|
+
set bannerWidget(widget) {
|
|
199
|
+
this.removeChild(this._banner);
|
|
200
|
+
this._banner = widget;
|
|
201
|
+
// Insert as first child so it stays at the top
|
|
202
|
+
this.children.unshift(widget);
|
|
203
|
+
widget.parent = this;
|
|
204
|
+
this.invalidate();
|
|
205
|
+
}
|
|
206
|
+
/** Get the current banner widget. */
|
|
207
|
+
get bannerWidget() {
|
|
208
|
+
return this._banner;
|
|
209
|
+
}
|
|
210
|
+
// ── Public API: Footer ─────────────────────────────────────────
|
|
211
|
+
/** Set footer content (plain string or StyledSpan for mixed colors). */
|
|
212
|
+
setFooter(content) {
|
|
213
|
+
this._footer.lines = [content];
|
|
214
|
+
this.invalidate();
|
|
215
|
+
}
|
|
216
|
+
// ── Public API: Feed ───────────────────────────────────────────
|
|
217
|
+
/** Append a line of plain text to the feed. Auto-scrolls to bottom. */
|
|
218
|
+
appendToFeed(text, style) {
|
|
219
|
+
const line = new StyledText({
|
|
220
|
+
lines: [text],
|
|
221
|
+
defaultStyle: style ?? this._feedStyle,
|
|
222
|
+
wrap: true,
|
|
223
|
+
});
|
|
224
|
+
this._feedLines.push(line);
|
|
225
|
+
this._autoScrollToBottom();
|
|
226
|
+
this.invalidate();
|
|
227
|
+
}
|
|
228
|
+
/** Append a styled line (StyledSpan) to the feed. */
|
|
229
|
+
appendStyledToFeed(styledLine) {
|
|
230
|
+
const line = new StyledText({
|
|
231
|
+
lines: [styledLine],
|
|
232
|
+
defaultStyle: this._feedStyle,
|
|
233
|
+
wrap: true,
|
|
234
|
+
});
|
|
235
|
+
this._feedLines.push(line);
|
|
236
|
+
this._autoScrollToBottom();
|
|
237
|
+
this.invalidate();
|
|
238
|
+
}
|
|
239
|
+
/** Append a clickable action line to the feed. Emits "action" on click. */
|
|
240
|
+
appendAction(id, normalContent, hoverContent) {
|
|
241
|
+
const line = new StyledText({
|
|
242
|
+
lines: [normalContent],
|
|
243
|
+
defaultStyle: this._feedStyle,
|
|
244
|
+
wrap: false,
|
|
245
|
+
});
|
|
246
|
+
const idx = this._feedLines.length;
|
|
247
|
+
this._feedLines.push(line);
|
|
248
|
+
this._feedActions.set(idx, {
|
|
249
|
+
items: [{ id, normalStyle: normalContent, hoverStyle: hoverContent }],
|
|
250
|
+
normalStyle: normalContent,
|
|
251
|
+
});
|
|
252
|
+
this._autoScrollToBottom();
|
|
253
|
+
this.invalidate();
|
|
254
|
+
}
|
|
255
|
+
/** Append a line with multiple side-by-side clickable actions. */
|
|
256
|
+
appendActionList(actions) {
|
|
257
|
+
if (actions.length === 0)
|
|
258
|
+
return;
|
|
259
|
+
const combined = this._concatSpans(actions.map((a) => a.normalStyle));
|
|
260
|
+
const line = new StyledText({
|
|
261
|
+
lines: [combined],
|
|
262
|
+
defaultStyle: this._feedStyle,
|
|
263
|
+
wrap: false,
|
|
264
|
+
});
|
|
265
|
+
const idx = this._feedLines.length;
|
|
266
|
+
this._feedLines.push(line);
|
|
267
|
+
this._feedActions.set(idx, { items: actions, normalStyle: combined });
|
|
268
|
+
this._autoScrollToBottom();
|
|
269
|
+
this.invalidate();
|
|
270
|
+
}
|
|
271
|
+
/** Concatenate multiple StyledLine arrays into one. */
|
|
272
|
+
_concatSpans(spans) {
|
|
273
|
+
const result = [];
|
|
274
|
+
for (const s of spans) {
|
|
275
|
+
if (Array.isArray(s))
|
|
276
|
+
result.push(...s);
|
|
277
|
+
else
|
|
278
|
+
result.push(s);
|
|
279
|
+
}
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
/** Append multiple plain lines to the feed. */
|
|
283
|
+
appendLines(lines, style) {
|
|
284
|
+
for (const text of lines) {
|
|
285
|
+
const line = new StyledText({
|
|
286
|
+
lines: [text],
|
|
287
|
+
defaultStyle: style ?? this._feedStyle,
|
|
288
|
+
wrap: true,
|
|
289
|
+
});
|
|
290
|
+
this._feedLines.push(line);
|
|
291
|
+
}
|
|
292
|
+
this._autoScrollToBottom();
|
|
293
|
+
this.invalidate();
|
|
294
|
+
}
|
|
295
|
+
/** Clear everything between the banner and the input box. */
|
|
296
|
+
clear() {
|
|
297
|
+
this._feedLines = [];
|
|
298
|
+
this._feedActions.clear();
|
|
299
|
+
this._hoveredAction = -1;
|
|
300
|
+
this._feedScrollOffset = 0;
|
|
301
|
+
this.invalidate();
|
|
302
|
+
}
|
|
303
|
+
/** Total number of feed lines. */
|
|
304
|
+
get feedLineCount() {
|
|
305
|
+
return this._feedLines.length;
|
|
306
|
+
}
|
|
307
|
+
/** Update the content of an existing feed line by index. Also removes its action if any. */
|
|
308
|
+
updateFeedLine(index, content) {
|
|
309
|
+
if (index < 0 || index >= this._feedLines.length)
|
|
310
|
+
return;
|
|
311
|
+
this._feedLines[index].lines = [content];
|
|
312
|
+
this._feedActions.delete(index);
|
|
313
|
+
if (this._hoveredAction === index)
|
|
314
|
+
this._hoveredAction = -1;
|
|
315
|
+
this.invalidate();
|
|
316
|
+
}
|
|
317
|
+
/** Scroll the feed to the bottom. */
|
|
318
|
+
scrollToBottom() {
|
|
319
|
+
this._autoScrollToBottom();
|
|
320
|
+
this.invalidate();
|
|
321
|
+
}
|
|
322
|
+
/** Scroll the feed by a delta (positive = down, negative = up). */
|
|
323
|
+
scrollFeed(delta) {
|
|
324
|
+
this._feedScrollOffset = Math.max(0, this._feedScrollOffset + delta);
|
|
325
|
+
this.invalidate();
|
|
326
|
+
}
|
|
327
|
+
// ── Public API: Input ──────────────────────────────────────────
|
|
328
|
+
/** Get current input value. */
|
|
329
|
+
get inputValue() {
|
|
330
|
+
return this._input.value;
|
|
331
|
+
}
|
|
332
|
+
/** Set the input value and move cursor to end. */
|
|
333
|
+
set inputValue(text) {
|
|
334
|
+
this._input.setValue(text);
|
|
335
|
+
}
|
|
336
|
+
/** Get the underlying TextInput for advanced use. */
|
|
337
|
+
get input() {
|
|
338
|
+
return this._input;
|
|
339
|
+
}
|
|
340
|
+
/** Get input history. */
|
|
341
|
+
get history() {
|
|
342
|
+
return this._input.history;
|
|
343
|
+
}
|
|
344
|
+
/** Set the input prompt text. */
|
|
345
|
+
set prompt(text) {
|
|
346
|
+
this._input.prompt = text;
|
|
347
|
+
}
|
|
348
|
+
get prompt() {
|
|
349
|
+
return this._input.prompt;
|
|
350
|
+
}
|
|
351
|
+
// ── Public API: Progress ───────────────────────────────────────
|
|
352
|
+
/** Show a progress/status message just above the separator. */
|
|
353
|
+
setProgress(content) {
|
|
354
|
+
if (content === null ||
|
|
355
|
+
(typeof content === "string" && content.length === 0)) {
|
|
356
|
+
this._progressText.lines = [];
|
|
357
|
+
this._progressText.visible = false;
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
this._progressText.lines = [content];
|
|
361
|
+
this._progressText.visible = true;
|
|
362
|
+
}
|
|
363
|
+
this.invalidate();
|
|
364
|
+
}
|
|
365
|
+
// ── Public API: Dropdown ───────────────────────────────────────
|
|
366
|
+
/** Show dropdown items below the input box. */
|
|
367
|
+
showDropdown(items) {
|
|
368
|
+
this._dropdownItems = items;
|
|
369
|
+
this._dropdownIndex = items.length > 0 ? 0 : -1;
|
|
370
|
+
this.invalidate();
|
|
371
|
+
}
|
|
372
|
+
/** Hide the dropdown. */
|
|
373
|
+
hideDropdown() {
|
|
374
|
+
this._dropdownItems = [];
|
|
375
|
+
this._dropdownIndex = -1;
|
|
376
|
+
this.invalidate();
|
|
377
|
+
}
|
|
378
|
+
/** Move dropdown selection down. */
|
|
379
|
+
dropdownDown() {
|
|
380
|
+
if (this._dropdownItems.length === 0)
|
|
381
|
+
return false;
|
|
382
|
+
this._dropdownIndex = Math.min(this._dropdownIndex + 1, this._dropdownItems.length - 1);
|
|
383
|
+
this.invalidate();
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
/** Move dropdown selection up. */
|
|
387
|
+
dropdownUp() {
|
|
388
|
+
if (this._dropdownItems.length === 0)
|
|
389
|
+
return false;
|
|
390
|
+
this._dropdownIndex = Math.max(this._dropdownIndex - 1, 0);
|
|
391
|
+
this.invalidate();
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
/** Accept the currently highlighted dropdown item. Returns it, or null. */
|
|
395
|
+
acceptDropdownItem() {
|
|
396
|
+
if (this._dropdownIndex < 0 ||
|
|
397
|
+
this._dropdownIndex >= this._dropdownItems.length) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
const item = this._dropdownItems[this._dropdownIndex];
|
|
401
|
+
this._input.setValue(item.completion);
|
|
402
|
+
this.hideDropdown();
|
|
403
|
+
this.emit("change", item.completion);
|
|
404
|
+
return item;
|
|
405
|
+
}
|
|
406
|
+
/** Get current dropdown items. */
|
|
407
|
+
get dropdownItems() {
|
|
408
|
+
return this._dropdownItems;
|
|
409
|
+
}
|
|
410
|
+
/** Get current dropdown selection index. */
|
|
411
|
+
get dropdownIndex() {
|
|
412
|
+
return this._dropdownIndex;
|
|
413
|
+
}
|
|
414
|
+
// ── Public API: Input Override ─────────────────────────────────
|
|
415
|
+
/**
|
|
416
|
+
* Replace the normal input/footer area with a custom widget
|
|
417
|
+
* (e.g. an Interview). While an override is active the normal
|
|
418
|
+
* input, separator, footer and dropdown are hidden and input
|
|
419
|
+
* events are routed to the override widget.
|
|
420
|
+
*
|
|
421
|
+
* Pass `null` to remove the override and restore normal input.
|
|
422
|
+
*/
|
|
423
|
+
setInputOverride(widget) {
|
|
424
|
+
// Remove previous override
|
|
425
|
+
if (this._inputOverride) {
|
|
426
|
+
this.removeChild(this._inputOverride);
|
|
427
|
+
}
|
|
428
|
+
this._inputOverride = widget;
|
|
429
|
+
if (widget) {
|
|
430
|
+
this.addChild(widget);
|
|
431
|
+
// Hide normal input chrome
|
|
432
|
+
this._input.visible = false;
|
|
433
|
+
this._input.focusable = false;
|
|
434
|
+
this._inputSeparator.visible = false;
|
|
435
|
+
this._footer.visible = false;
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
// Restore normal input chrome
|
|
439
|
+
this._input.visible = true;
|
|
440
|
+
this._input.focusable = true;
|
|
441
|
+
this._input.onFocus();
|
|
442
|
+
this._inputSeparator.visible = true;
|
|
443
|
+
this._footer.visible = true;
|
|
444
|
+
}
|
|
445
|
+
this.invalidate();
|
|
446
|
+
}
|
|
447
|
+
/** Get the current input override widget, or null. */
|
|
448
|
+
get inputOverride() {
|
|
449
|
+
return this._inputOverride;
|
|
450
|
+
}
|
|
451
|
+
// ── Input handling ─────────────────────────────────────────────
|
|
452
|
+
handleInput(event) {
|
|
453
|
+
if (event.type === "key") {
|
|
454
|
+
const ke = event.event;
|
|
455
|
+
// Ctrl+C → emit for the app to handle
|
|
456
|
+
if (ke.key === "c" && ke.ctrl && !ke.alt && !ke.shift) {
|
|
457
|
+
this.emit("ctrlc");
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
// Dropdown navigation
|
|
461
|
+
if (this._dropdownItems.length > 0) {
|
|
462
|
+
if (ke.key === "up")
|
|
463
|
+
return this.dropdownUp();
|
|
464
|
+
if (ke.key === "down")
|
|
465
|
+
return this.dropdownDown();
|
|
466
|
+
if (ke.key === "enter" && this._dropdownIndex >= 0) {
|
|
467
|
+
// Only consume Enter if the highlighted item has a completion value
|
|
468
|
+
// AND the input doesn't already match the completion (otherwise submit).
|
|
469
|
+
const item = this._dropdownItems[this._dropdownIndex];
|
|
470
|
+
if (item?.completion) {
|
|
471
|
+
const currentVal = this._input.value.trim();
|
|
472
|
+
if (currentVal !== item.completion.trim()) {
|
|
473
|
+
this.acceptDropdownItem();
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
// Input already matches — hide dropdown and let Enter fall through to submit
|
|
477
|
+
this.hideDropdown();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (ke.key === "escape") {
|
|
481
|
+
this.hideDropdown();
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// Mouse wheel scrolling for feed
|
|
486
|
+
if (ke.key === "pageup") {
|
|
487
|
+
this.scrollFeed(-10);
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
if (ke.key === "pagedown") {
|
|
491
|
+
this.scrollFeed(10);
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// Mouse events: wheel scrolling + scrollbar drag
|
|
496
|
+
if (event.type === "mouse") {
|
|
497
|
+
const me = event.event;
|
|
498
|
+
if (me.type === "wheelup") {
|
|
499
|
+
this.scrollFeed(-3);
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
if (me.type === "wheeldown") {
|
|
503
|
+
this.scrollFeed(3);
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
// Scrollbar drag
|
|
507
|
+
if (this._scrollbarVisible) {
|
|
508
|
+
const onScrollbar = me.x === this._scrollbarX &&
|
|
509
|
+
me.y >= this._feedY &&
|
|
510
|
+
me.y < this._feedY + this._feedH;
|
|
511
|
+
if (me.type === "press" && me.button === "left" && onScrollbar) {
|
|
512
|
+
const relY = me.y - this._feedY;
|
|
513
|
+
if (relY >= this._thumbPos &&
|
|
514
|
+
relY < this._thumbPos + this._thumbSize) {
|
|
515
|
+
// Clicked on thumb — start dragging
|
|
516
|
+
this._dragging = true;
|
|
517
|
+
this._dragOffsetY = relY - this._thumbPos;
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
// Clicked on track — jump to that position
|
|
521
|
+
const ratio = relY / this._feedH;
|
|
522
|
+
this._feedScrollOffset = Math.round(ratio * this._maxScroll);
|
|
523
|
+
this._feedScrollOffset = Math.max(0, Math.min(this._feedScrollOffset, this._maxScroll));
|
|
524
|
+
this.invalidate();
|
|
525
|
+
}
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
if (me.type === "move" && this._dragging) {
|
|
529
|
+
const relY = me.y - this._feedY;
|
|
530
|
+
const newThumbPos = relY - this._dragOffsetY;
|
|
531
|
+
const maxThumbPos = this._feedH - this._thumbSize;
|
|
532
|
+
const clampedPos = Math.max(0, Math.min(newThumbPos, maxThumbPos));
|
|
533
|
+
const ratio = maxThumbPos > 0 ? clampedPos / maxThumbPos : 0;
|
|
534
|
+
this._feedScrollOffset = Math.round(ratio * this._maxScroll);
|
|
535
|
+
this.invalidate();
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
if (me.type === "release" && this._dragging) {
|
|
539
|
+
this._dragging = false;
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// Ctrl+click to open URLs in browser
|
|
544
|
+
if (me.type === "press" && me.button === "left" && me.ctrl) {
|
|
545
|
+
const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
|
|
546
|
+
if (feedLineIdx >= 0) {
|
|
547
|
+
const text = this._extractFeedLineText(feedLineIdx);
|
|
548
|
+
URL_REGEX.lastIndex = 0;
|
|
549
|
+
const urls = [...text.matchAll(URL_REGEX)];
|
|
550
|
+
if (urls.length === 1) {
|
|
551
|
+
this.emit("link", urls[0][0]);
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
if (urls.length > 1) {
|
|
555
|
+
// Try to resolve which URL based on click position
|
|
556
|
+
const row = this._screenToFeedRow.get(me.y) ?? 0;
|
|
557
|
+
const col = me.x - this._feedX;
|
|
558
|
+
const charOffset = row * this._contentWidth + col;
|
|
559
|
+
const hit = this._findUrlAtOffset(text, charOffset);
|
|
560
|
+
this.emit("link", hit ?? urls[0][0]);
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// Action hover/click in feed area
|
|
566
|
+
if (this._feedActions.size > 0) {
|
|
567
|
+
const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
|
|
568
|
+
const entry = feedLineIdx >= 0 ? this._feedActions.get(feedLineIdx) : undefined;
|
|
569
|
+
if (me.type === "move") {
|
|
570
|
+
const newHover = entry ? feedLineIdx : -1;
|
|
571
|
+
if (newHover !== this._hoveredAction ||
|
|
572
|
+
(entry && entry.items.length > 1)) {
|
|
573
|
+
// Restore previous hover
|
|
574
|
+
if (this._hoveredAction >= 0) {
|
|
575
|
+
const prev = this._feedActions.get(this._hoveredAction);
|
|
576
|
+
if (prev) {
|
|
577
|
+
this._feedLines[this._hoveredAction].lines = [prev.normalStyle];
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Apply new hover — highlight only the hovered action item
|
|
581
|
+
if (entry && newHover >= 0) {
|
|
582
|
+
const hitItem = this._resolveActionItem(entry, me.x);
|
|
583
|
+
const hoverLine = this._buildHoverLine(entry, hitItem);
|
|
584
|
+
this._feedLines[newHover].lines = [hoverLine];
|
|
585
|
+
}
|
|
586
|
+
this._hoveredAction = newHover;
|
|
587
|
+
this.invalidate();
|
|
588
|
+
}
|
|
589
|
+
// Don't consume — let other handlers run too
|
|
590
|
+
}
|
|
591
|
+
if (me.type === "press" && me.button === "left" && entry) {
|
|
592
|
+
const hitItem = this._resolveActionItem(entry, me.x);
|
|
593
|
+
if (hitItem)
|
|
594
|
+
this.emit("action", hitItem.id);
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// Delegate to override widget or normal input
|
|
600
|
+
if (this._inputOverride) {
|
|
601
|
+
return this._inputOverride.handleInput(event);
|
|
602
|
+
}
|
|
603
|
+
return this._input.handleInput(event);
|
|
604
|
+
}
|
|
605
|
+
/** Extract the plain text content of a feed line. */
|
|
606
|
+
_extractFeedLineText(idx) {
|
|
607
|
+
const styledText = this._feedLines[idx];
|
|
608
|
+
if (!styledText)
|
|
609
|
+
return "";
|
|
610
|
+
return styledText.lines
|
|
611
|
+
.map((line) => {
|
|
612
|
+
if (typeof line === "string")
|
|
613
|
+
return line;
|
|
614
|
+
return line.map((seg) => seg.text).join("");
|
|
615
|
+
})
|
|
616
|
+
.join("\n");
|
|
617
|
+
}
|
|
618
|
+
/** Find the URL at the given character offset, if any. */
|
|
619
|
+
_findUrlAtOffset(text, charOffset) {
|
|
620
|
+
URL_REGEX.lastIndex = 0;
|
|
621
|
+
let match;
|
|
622
|
+
while ((match = URL_REGEX.exec(text)) !== null) {
|
|
623
|
+
if (charOffset >= match.index &&
|
|
624
|
+
charOffset < match.index + match[0].length) {
|
|
625
|
+
return match[0];
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
/** Resolve which action item the mouse x-position falls on. */
|
|
631
|
+
_resolveActionItem(entry, x) {
|
|
632
|
+
if (entry.items.length === 1)
|
|
633
|
+
return entry.items[0];
|
|
634
|
+
// Calculate text length of each item's normal style to find boundaries
|
|
635
|
+
let col = 0;
|
|
636
|
+
for (const item of entry.items) {
|
|
637
|
+
const len = this._spanTextLength(item.normalStyle);
|
|
638
|
+
if (x < col + len)
|
|
639
|
+
return item;
|
|
640
|
+
col += len;
|
|
641
|
+
}
|
|
642
|
+
return entry.items[entry.items.length - 1];
|
|
643
|
+
}
|
|
644
|
+
/** Build a hover line: highlight only the target item, keep others normal. */
|
|
645
|
+
_buildHoverLine(entry, target) {
|
|
646
|
+
if (entry.items.length === 1 && target)
|
|
647
|
+
return target.hoverStyle;
|
|
648
|
+
const parts = entry.items.map((item) => item === target ? item.hoverStyle : item.normalStyle);
|
|
649
|
+
return this._concatSpans(parts);
|
|
650
|
+
}
|
|
651
|
+
/** Get the plain text length of a StyledLine. */
|
|
652
|
+
_spanTextLength(span) {
|
|
653
|
+
if (typeof span === "string")
|
|
654
|
+
return span.length;
|
|
655
|
+
const segments = span;
|
|
656
|
+
if (!Array.isArray(segments))
|
|
657
|
+
return 0;
|
|
658
|
+
let len = 0;
|
|
659
|
+
for (const seg of segments) {
|
|
660
|
+
if (typeof seg === "string")
|
|
661
|
+
len += seg.length;
|
|
662
|
+
else if (seg && typeof seg === "object" && "text" in seg)
|
|
663
|
+
len += seg.text.length;
|
|
664
|
+
}
|
|
665
|
+
return len;
|
|
666
|
+
}
|
|
667
|
+
// ── Layout ─────────────────────────────────────────────────────
|
|
668
|
+
measure(constraint) {
|
|
669
|
+
// ChatView always fills the full available space
|
|
670
|
+
const size = {
|
|
671
|
+
width: constraint.maxWidth,
|
|
672
|
+
height: constraint.maxHeight,
|
|
673
|
+
};
|
|
674
|
+
this.desiredSize = size;
|
|
675
|
+
return size;
|
|
676
|
+
}
|
|
677
|
+
arrange(rect) {
|
|
678
|
+
this.bounds = rect;
|
|
679
|
+
// Actual child arrangement happens in render() because we need
|
|
680
|
+
// to know the computed heights of variable-height elements.
|
|
681
|
+
}
|
|
682
|
+
// ── Render ─────────────────────────────────────────────────────
|
|
683
|
+
render(ctx) {
|
|
684
|
+
const b = this.bounds;
|
|
685
|
+
if (!b || b.width < 1 || b.height < 3)
|
|
686
|
+
return;
|
|
687
|
+
const W = b.width;
|
|
688
|
+
const H = b.height;
|
|
689
|
+
// ── Measure fixed-height sections ────────────────────────
|
|
690
|
+
// Progress text height (always 1 row when visible)
|
|
691
|
+
let progressH = 0;
|
|
692
|
+
if (this._progressText.visible && this._progressText.lines.length > 0) {
|
|
693
|
+
progressH = 1;
|
|
694
|
+
}
|
|
695
|
+
// Bottom separator: 1 row
|
|
696
|
+
const botSepH = 1;
|
|
697
|
+
// When an input override is active, it replaces input + inputSep + footer
|
|
698
|
+
if (this._inputOverride) {
|
|
699
|
+
// Measure the override widget
|
|
700
|
+
const overrideSize = this._inputOverride.measure({
|
|
701
|
+
minWidth: 0,
|
|
702
|
+
maxWidth: W,
|
|
703
|
+
minHeight: 0,
|
|
704
|
+
maxHeight: Math.max(1, Math.floor(H / 2)), // up to half the screen
|
|
705
|
+
});
|
|
706
|
+
const overrideH = overrideSize.height;
|
|
707
|
+
const chromeH = botSepH + progressH + overrideH;
|
|
708
|
+
const feedH = Math.max(0, H - chromeH);
|
|
709
|
+
let y = b.y;
|
|
710
|
+
// 1. Feed area
|
|
711
|
+
if (feedH > 0) {
|
|
712
|
+
this._renderFeed(ctx, b.x, y, W, feedH);
|
|
713
|
+
y += feedH;
|
|
714
|
+
}
|
|
715
|
+
// 2. Progress text
|
|
716
|
+
if (progressH > 0) {
|
|
717
|
+
this._progressText.measure({
|
|
718
|
+
minWidth: 0,
|
|
719
|
+
maxWidth: W,
|
|
720
|
+
minHeight: 0,
|
|
721
|
+
maxHeight: 1,
|
|
722
|
+
});
|
|
723
|
+
this._progressText.arrange({
|
|
724
|
+
x: b.x,
|
|
725
|
+
y,
|
|
726
|
+
width: W,
|
|
727
|
+
height: progressH,
|
|
728
|
+
});
|
|
729
|
+
this._progressText.render(ctx);
|
|
730
|
+
y += progressH;
|
|
731
|
+
}
|
|
732
|
+
// 3. Bottom separator
|
|
733
|
+
this._bottomSeparator.arrange({ x: b.x, y, width: W, height: 1 });
|
|
734
|
+
this._bottomSeparator.render(ctx);
|
|
735
|
+
y += 1;
|
|
736
|
+
// 4. Override widget (replaces input + inputSep + footer)
|
|
737
|
+
this._inputOverride.arrange({
|
|
738
|
+
x: b.x,
|
|
739
|
+
y,
|
|
740
|
+
width: W,
|
|
741
|
+
height: overrideH,
|
|
742
|
+
});
|
|
743
|
+
this._inputOverride.render(ctx);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
// ── Normal input mode ────────────────────────────────────
|
|
747
|
+
// Input: measure to get wrapped height (up to maxInputH rows)
|
|
748
|
+
const inputSize = this._input.measure({
|
|
749
|
+
minWidth: 0,
|
|
750
|
+
maxWidth: W,
|
|
751
|
+
minHeight: 0,
|
|
752
|
+
maxHeight: this._maxInputH,
|
|
753
|
+
});
|
|
754
|
+
const inputH = inputSize.height;
|
|
755
|
+
// Input separator: 1 row (between input and footer/dropdown)
|
|
756
|
+
const inputSepH = 1;
|
|
757
|
+
// Footer: always 1 row (shows footer text or first row of dropdown)
|
|
758
|
+
const footerH = 1;
|
|
759
|
+
// Dropdown height — when active, replaces the footer row and can grow.
|
|
760
|
+
const chromeH = botSepH + progressH + inputH + inputSepH + footerH;
|
|
761
|
+
const hasDropdown = this._dropdownItems.length > 0;
|
|
762
|
+
const maxDropdownH = Math.max(0, H - chromeH);
|
|
763
|
+
const dropdownExtraH = hasDropdown
|
|
764
|
+
? Math.min(this._dropdownItems.length - 1, maxDropdownH)
|
|
765
|
+
: 0;
|
|
766
|
+
// Feed gets remaining space (banner + separator scroll within it)
|
|
767
|
+
const fixedH = chromeH + dropdownExtraH;
|
|
768
|
+
const feedH = Math.max(0, H - fixedH);
|
|
769
|
+
// ── Arrange and render each section ──────────────────────
|
|
770
|
+
let y = b.y;
|
|
771
|
+
// 1. Feed area (banner + separator + feed lines all scroll together)
|
|
772
|
+
if (feedH > 0) {
|
|
773
|
+
this._renderFeed(ctx, b.x, y, W, feedH);
|
|
774
|
+
y += feedH;
|
|
775
|
+
}
|
|
776
|
+
// 2. Progress text (above separator, fixed — not part of scrollable feed)
|
|
777
|
+
if (progressH > 0) {
|
|
778
|
+
this._progressText.measure({
|
|
779
|
+
minWidth: 0,
|
|
780
|
+
maxWidth: W,
|
|
781
|
+
minHeight: 0,
|
|
782
|
+
maxHeight: 1,
|
|
783
|
+
});
|
|
784
|
+
this._progressText.arrange({ x: b.x, y, width: W, height: progressH });
|
|
785
|
+
this._progressText.render(ctx);
|
|
786
|
+
y += progressH;
|
|
787
|
+
}
|
|
788
|
+
// 3. Bottom separator
|
|
789
|
+
this._bottomSeparator.arrange({ x: b.x, y, width: W, height: 1 });
|
|
790
|
+
this._bottomSeparator.render(ctx);
|
|
791
|
+
y += 1;
|
|
792
|
+
// 4. Input
|
|
793
|
+
this._input.arrange({ x: b.x, y, width: W, height: inputH });
|
|
794
|
+
this._input.render(ctx);
|
|
795
|
+
y += inputH;
|
|
796
|
+
// 5. Input separator
|
|
797
|
+
this._inputSeparator.arrange({ x: b.x, y, width: W, height: 1 });
|
|
798
|
+
this._inputSeparator.render(ctx);
|
|
799
|
+
y += inputSepH;
|
|
800
|
+
// 6. Dropdown or footer
|
|
801
|
+
if (hasDropdown) {
|
|
802
|
+
const totalDropdownH = dropdownExtraH + 1;
|
|
803
|
+
this._renderDropdown(ctx, b.x, y, W, totalDropdownH);
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
this._footer.measure({
|
|
807
|
+
minWidth: 0,
|
|
808
|
+
maxWidth: W,
|
|
809
|
+
minHeight: 0,
|
|
810
|
+
maxHeight: 1,
|
|
811
|
+
});
|
|
812
|
+
this._footer.arrange({ x: b.x, y, width: W, height: footerH });
|
|
813
|
+
this._footer.render(ctx);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// ── Feed rendering ─────────────────────────────────────────────
|
|
817
|
+
_renderFeed(ctx, x, y, width, height) {
|
|
818
|
+
// Build the list of scrollable items: banner + separator + feed lines
|
|
819
|
+
// Each item is { control, height } measured against content width.
|
|
820
|
+
const contentWidth = width - 1; // reserve 1 col for scrollbar
|
|
821
|
+
this._feedX = x;
|
|
822
|
+
this._contentWidth = contentWidth;
|
|
823
|
+
const items = [];
|
|
824
|
+
// Banner (if visible)
|
|
825
|
+
if (this._banner.visible) {
|
|
826
|
+
const bannerSize = this._banner.measure({
|
|
827
|
+
minWidth: 0,
|
|
828
|
+
maxWidth: contentWidth,
|
|
829
|
+
minHeight: 0,
|
|
830
|
+
maxHeight: Infinity,
|
|
831
|
+
});
|
|
832
|
+
const bh = Math.max(1, bannerSize.height);
|
|
833
|
+
items.push({
|
|
834
|
+
height: bh,
|
|
835
|
+
feedLineIdx: -1,
|
|
836
|
+
render: (cx, cy, cw, ch) => {
|
|
837
|
+
this._banner.arrange({ x: cx, y: cy, width: cw, height: ch });
|
|
838
|
+
this._banner.render(ctx);
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
// Top separator after banner
|
|
842
|
+
items.push({
|
|
843
|
+
height: 1,
|
|
844
|
+
feedLineIdx: -1,
|
|
845
|
+
render: (cx, cy, cw, _ch) => {
|
|
846
|
+
this._topSeparator.arrange({ x: cx, y: cy, width: cw, height: 1 });
|
|
847
|
+
this._topSeparator.render(ctx);
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
// Feed lines
|
|
852
|
+
for (let fi = 0; fi < this._feedLines.length; fi++) {
|
|
853
|
+
const line = this._feedLines[fi];
|
|
854
|
+
const lineSize = line.measure({
|
|
855
|
+
minWidth: 0,
|
|
856
|
+
maxWidth: contentWidth,
|
|
857
|
+
minHeight: 0,
|
|
858
|
+
maxHeight: Infinity,
|
|
859
|
+
});
|
|
860
|
+
const h = Math.max(1, lineSize.height);
|
|
861
|
+
items.push({
|
|
862
|
+
height: h,
|
|
863
|
+
feedLineIdx: fi,
|
|
864
|
+
render: (cx, cy, cw, ch) => {
|
|
865
|
+
line.arrange({ x: cx, y: cy, width: cw, height: ch });
|
|
866
|
+
line.render(ctx);
|
|
867
|
+
},
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
// Calculate total content height
|
|
871
|
+
let totalContentH = 0;
|
|
872
|
+
for (const item of items) {
|
|
873
|
+
totalContentH += item.height;
|
|
874
|
+
}
|
|
875
|
+
// Clamp scroll offset
|
|
876
|
+
const maxScroll = Math.max(0, totalContentH - height);
|
|
877
|
+
this._feedScrollOffset = Math.max(0, Math.min(this._feedScrollOffset, maxScroll));
|
|
878
|
+
// Clip feed area
|
|
879
|
+
ctx.pushClip({ x, y, width, height });
|
|
880
|
+
// Find the first visible item
|
|
881
|
+
let skippedRows = 0;
|
|
882
|
+
let startIdx = 0;
|
|
883
|
+
for (let i = 0; i < items.length; i++) {
|
|
884
|
+
if (skippedRows + items[i].height > this._feedScrollOffset)
|
|
885
|
+
break;
|
|
886
|
+
skippedRows += items[i].height;
|
|
887
|
+
startIdx = i + 1;
|
|
888
|
+
}
|
|
889
|
+
// Render visible items and build screen→feedLine map
|
|
890
|
+
this._screenToFeedLine.clear();
|
|
891
|
+
this._screenToFeedRow.clear();
|
|
892
|
+
let cy = y - (this._feedScrollOffset - skippedRows);
|
|
893
|
+
for (let i = startIdx; i < items.length && cy < y + height; i++) {
|
|
894
|
+
const item = items[i];
|
|
895
|
+
item.render(x, cy, contentWidth, item.height);
|
|
896
|
+
// Map screen rows to feed line index + row offset for hit-testing
|
|
897
|
+
if (item.feedLineIdx >= 0) {
|
|
898
|
+
for (let row = 0; row < item.height; row++) {
|
|
899
|
+
const screenY = cy + row;
|
|
900
|
+
if (screenY >= y && screenY < y + height) {
|
|
901
|
+
this._screenToFeedLine.set(screenY, item.feedLineIdx);
|
|
902
|
+
this._screenToFeedRow.set(screenY, row);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
cy += item.height;
|
|
907
|
+
}
|
|
908
|
+
// Render scrollbar and cache geometry for hit-testing
|
|
909
|
+
if (height > 0 && totalContentH > height) {
|
|
910
|
+
const scrollX = x + width - 1;
|
|
911
|
+
const thumbSize = Math.max(1, Math.round((height / totalContentH) * height));
|
|
912
|
+
const thumbPos = maxScroll > 0
|
|
913
|
+
? Math.round((this._feedScrollOffset / maxScroll) * (height - thumbSize))
|
|
914
|
+
: 0;
|
|
915
|
+
const trackStyle = this._separatorStyle;
|
|
916
|
+
const thumbStyle = this._feedStyle;
|
|
917
|
+
// Cache for mouse interaction
|
|
918
|
+
this._scrollbarX = scrollX;
|
|
919
|
+
this._feedY = y;
|
|
920
|
+
this._feedH = height;
|
|
921
|
+
this._thumbPos = thumbPos;
|
|
922
|
+
this._thumbSize = thumbSize;
|
|
923
|
+
this._maxScroll = maxScroll;
|
|
924
|
+
this._scrollbarVisible = true;
|
|
925
|
+
for (let row = 0; row < height; row++) {
|
|
926
|
+
const inThumb = row >= thumbPos && row < thumbPos + thumbSize;
|
|
927
|
+
ctx.drawChar(scrollX, y + row, inThumb ? "┃" : "│", inThumb ? thumbStyle : trackStyle);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
else {
|
|
931
|
+
this._scrollbarVisible = false;
|
|
932
|
+
}
|
|
933
|
+
ctx.popClip();
|
|
934
|
+
}
|
|
935
|
+
// ── Dropdown rendering ─────────────────────────────────────────
|
|
936
|
+
_renderDropdown(ctx, x, y, width, height) {
|
|
937
|
+
for (let i = 0; i < this._dropdownItems.length && i < height; i++) {
|
|
938
|
+
const item = this._dropdownItems[i];
|
|
939
|
+
const isHighlighted = i === this._dropdownIndex;
|
|
940
|
+
const style = isHighlighted
|
|
941
|
+
? this._dropdownHighlightStyle
|
|
942
|
+
: this._dropdownStyle;
|
|
943
|
+
const prefix = isHighlighted ? "▸ " : " ";
|
|
944
|
+
const labelPad = item.label.padEnd(16);
|
|
945
|
+
const text = prefix + labelPad + item.description;
|
|
946
|
+
const truncated = text.length > width ? text.slice(0, width) : text;
|
|
947
|
+
ctx.drawText(x, y + i, truncated, style);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
// ── Auto-scroll ────────────────────────────────────────────────
|
|
951
|
+
_autoScrollToBottom() {
|
|
952
|
+
// Set scroll to a very large value; it will be clamped during render
|
|
953
|
+
this._feedScrollOffset = Number.MAX_SAFE_INTEGER;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
// ── Internal: Separator line control ─────────────────────────────
|
|
957
|
+
/**
|
|
958
|
+
* Thin separator line that fills its width with a repeated character.
|
|
959
|
+
* Used for the horizontal rules between banner/feed/input.
|
|
960
|
+
*/
|
|
961
|
+
class _Separator extends Control {
|
|
962
|
+
_char;
|
|
963
|
+
_style;
|
|
964
|
+
constructor(char, style) {
|
|
965
|
+
super();
|
|
966
|
+
this._char = char;
|
|
967
|
+
this._style = style;
|
|
968
|
+
}
|
|
969
|
+
get separatorChar() {
|
|
970
|
+
return this._char;
|
|
971
|
+
}
|
|
972
|
+
set separatorChar(c) {
|
|
973
|
+
this._char = c;
|
|
974
|
+
this.invalidate();
|
|
975
|
+
}
|
|
976
|
+
get style() {
|
|
977
|
+
return this._style;
|
|
978
|
+
}
|
|
979
|
+
set style(s) {
|
|
980
|
+
this._style = s;
|
|
981
|
+
this.invalidate();
|
|
982
|
+
}
|
|
983
|
+
measure(_constraint) {
|
|
984
|
+
return { width: _constraint.maxWidth, height: 1 };
|
|
985
|
+
}
|
|
986
|
+
render(ctx) {
|
|
987
|
+
const b = this.bounds;
|
|
988
|
+
if (!b)
|
|
989
|
+
return;
|
|
990
|
+
const line = this._char.repeat(b.width);
|
|
991
|
+
ctx.drawText(b.x, b.y, line, this._style);
|
|
992
|
+
}
|
|
993
|
+
}
|