@uniflowed/tui 0.0.0-alpha.18 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/capability.js +15 -12
- package/cells.js +1 -1
- package/clipboard.js +61 -0
- package/components.js +766 -57
- package/index.js +41 -22
- package/internal/hits.js +2 -2
- package/internal/host.js +63 -22
- package/internal/paint.js +43 -2
- package/internal/tree.js +168 -38
- package/internal/widgets.js +771 -0
- package/keys.js +214 -15
- package/layout.js +653 -132
- package/package.json +5 -4
- package/terminal.js +49 -19
- package/widths.js +4 -1
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// What a `Select`, a `TabSelect` and a `Textarea` draw, and how large they are.
|
|
4
|
+
//
|
|
5
|
+
// # Internal to `@uniflowed/tui`
|
|
6
|
+
//
|
|
7
|
+
// Absent from `package.json#exports`, for the reason `paint.js` is: the
|
|
8
|
+
// `Textarea` component moves its cursor up and down by the lines this module
|
|
9
|
+
// breaks its text into, and the painter draws the same lines. A consumer who
|
|
10
|
+
// could call {@link editLines} with a width the node was not given would move a
|
|
11
|
+
// cursor through lines nobody can see.
|
|
12
|
+
//
|
|
13
|
+
// # Why these three are painted, and not composed out of `Box` and `Text`
|
|
14
|
+
//
|
|
15
|
+
// `components.js` says most of what OpenTUI offers beyond its first four
|
|
16
|
+
// components is "those plus state", and for most of it that is true. For these three it is
|
|
17
|
+
// not, for one reason: what they show depends on how large layout made them.
|
|
18
|
+
//
|
|
19
|
+
// A `Select` keeps its selected row in the middle of the rows it has room for,
|
|
20
|
+
// so the first row it draws is a function of its height. A `TabSelect` shows as
|
|
21
|
+
// many tabs as fit across it, so the first tab it draws is a function of its
|
|
22
|
+
// width. A `Textarea` wraps at its width and scrolls to keep the cursor in its
|
|
23
|
+
// height. A component knows none of those while it renders — layout has not
|
|
24
|
+
// run yet — and a component that rendered twice to find out would draw one
|
|
25
|
+
// wrong frame every time its size changed.
|
|
26
|
+
//
|
|
27
|
+
// So each is one node, a box with nothing inside it, and this module draws
|
|
28
|
+
// into it after layout has sized it. That is also exactly what OpenTUI does:
|
|
29
|
+
// its `SelectRenderable`, `TabSelectRenderable` and `TextareaRenderable` are
|
|
30
|
+
// renderables that fill their own frame buffer, not trees of boxes. The
|
|
31
|
+
// drawing below follows theirs cell for cell — the `▶ ` in front of the
|
|
32
|
+
// selected row, the name one column in and the description under it, the `▬`
|
|
33
|
+
// under a selected tab, the `‹` and `›` when there are more tabs than fit —
|
|
34
|
+
// and every place it differs says so beside the difference.
|
|
35
|
+
//
|
|
36
|
+
// The React component in `components.js` still owns the state — which row is
|
|
37
|
+
// selected, what the text is and where its cursor sits — because that is what
|
|
38
|
+
// React is for and what an application reads. What lives here is only what
|
|
39
|
+
// depends on the size: the window, and the lines.
|
|
40
|
+
|
|
41
|
+
import type { Capabilities } from "../capability.js";
|
|
42
|
+
import type { Color, Frame, Rect, Style } from "../cells.js";
|
|
43
|
+
import { Attributes, INHERIT, fillRect, intersect, parseColor, writeGrapheme } from "../cells.js";
|
|
44
|
+
import type { HitGrid } from "./hits.js";
|
|
45
|
+
import { recordText } from "./hits.js";
|
|
46
|
+
import type { TuiNode, Widget } from "./tree.js";
|
|
47
|
+
import { graphemeWidth } from "../widths.js";
|
|
48
|
+
|
|
49
|
+
/** One entry in a `Select` or a `TabSelect`: OpenTUI's `SelectOption`. */
|
|
50
|
+
export type SelectOption = {
|
|
51
|
+
readonly name: string,
|
|
52
|
+
readonly description: string,
|
|
53
|
+
readonly value?: mixed,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** A `Textarea`'s wrap modes, which are OpenTUI's. */
|
|
57
|
+
export type EditWrapMode = "word" | "char" | "none";
|
|
58
|
+
|
|
59
|
+
// OpenTUI's defaults for the colours that say which item is selected. They are
|
|
60
|
+
// fixed pairs — a dark slate behind a yellow name — rather than the terminal's
|
|
61
|
+
// own colours, because "selected" has to be visible on a terminal whose theme
|
|
62
|
+
// nobody here knows, and a pair that is legible against itself is legible on
|
|
63
|
+
// any background.
|
|
64
|
+
const SELECTED_BACKGROUND = 0x334455;
|
|
65
|
+
const SELECTED_TEXT = 0xffff00;
|
|
66
|
+
const DESCRIPTION = 0x888888;
|
|
67
|
+
const SELECTED_DESCRIPTION = 0xcccccc;
|
|
68
|
+
const SCROLL_INDICATOR = 0x666666;
|
|
69
|
+
const SCROLL_ARROWS = 0xaaaaaa;
|
|
70
|
+
const PLACEHOLDER = 0x666666;
|
|
71
|
+
|
|
72
|
+
/** OpenTUI's default tab width, in cells. */
|
|
73
|
+
export const DEFAULT_TAB_WIDTH = 20;
|
|
74
|
+
|
|
75
|
+
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
76
|
+
|
|
77
|
+
/** One grapheme of a `Textarea`'s text, and where it is in the string. */
|
|
78
|
+
export type EditCell = {
|
|
79
|
+
readonly text: string,
|
|
80
|
+
readonly width: number,
|
|
81
|
+
/** The string offset it starts at. */
|
|
82
|
+
readonly at: number,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One line of a `Textarea` as it is drawn.
|
|
87
|
+
*
|
|
88
|
+
* `start` and `end` are string offsets, `end` exclusive. `wrapped` is `true`
|
|
89
|
+
* when the logical line goes on in the next visual line rather than ending
|
|
90
|
+
* here, which is the one thing a cursor needs to know about a line besides its
|
|
91
|
+
* cells: a cursor at `end` of a wrapped line is drawn at the start of the next
|
|
92
|
+
* one, so "the end of this line" has to mean the last cell instead.
|
|
93
|
+
*/
|
|
94
|
+
export type EditLine = {
|
|
95
|
+
readonly start: number,
|
|
96
|
+
readonly end: number,
|
|
97
|
+
readonly cells: $ReadOnlyArray<EditCell>,
|
|
98
|
+
readonly width: number,
|
|
99
|
+
readonly wrapped: boolean,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Break a `Textarea`'s text into the lines it is drawn in.
|
|
104
|
+
*
|
|
105
|
+
* The same three modes as `Text`, with one difference that editing forces:
|
|
106
|
+
* `wrapRuns` in `paint.js` drops the space a word wrap broke at, and this keeps
|
|
107
|
+
* it at the end of the line it broke. A line of `Text` is read, so the space is
|
|
108
|
+
* nothing; a line of a `Textarea` has a cursor in it, and a cursor has to be
|
|
109
|
+
* able to stand on every character of the text — including that one, which a
|
|
110
|
+
* reader deletes by putting the cursor after it and pressing Backspace.
|
|
111
|
+
*
|
|
112
|
+
* A width of zero or less means "not laid out yet", and does not wrap.
|
|
113
|
+
*/
|
|
114
|
+
export function editLines(
|
|
115
|
+
text: string,
|
|
116
|
+
width: number,
|
|
117
|
+
mode: EditWrapMode,
|
|
118
|
+
): $ReadOnlyArray<EditLine> {
|
|
119
|
+
const lines: Array<EditLine> = [];
|
|
120
|
+
const wraps = mode !== "none" && width > 0;
|
|
121
|
+
let offset = 0;
|
|
122
|
+
for (const logical of text.split("\n")) {
|
|
123
|
+
let current: Array<EditCell> = [];
|
|
124
|
+
let used = 0;
|
|
125
|
+
// Where the next line begins when `current` is empty: the offset a line
|
|
126
|
+
// that holds nothing is at.
|
|
127
|
+
let from = offset;
|
|
128
|
+
let lastSpace = -1;
|
|
129
|
+
const push = (cells: Array<EditCell>, wrapped: boolean) => {
|
|
130
|
+
const last = cells[cells.length - 1];
|
|
131
|
+
const start = cells.length > 0 ? cells[0].at : from;
|
|
132
|
+
const end = last != null ? last.at + last.text.length : start;
|
|
133
|
+
let total = 0;
|
|
134
|
+
for (const cell of cells) {
|
|
135
|
+
total += cell.width;
|
|
136
|
+
}
|
|
137
|
+
lines.push({ start, end, cells, width: total, wrapped });
|
|
138
|
+
from = end;
|
|
139
|
+
};
|
|
140
|
+
for (const segment of SEGMENTER.segment(logical)) {
|
|
141
|
+
const cell = {
|
|
142
|
+
text: segment.segment,
|
|
143
|
+
width: graphemeWidth(segment.segment),
|
|
144
|
+
at: offset + segment.index,
|
|
145
|
+
};
|
|
146
|
+
if (wraps && used + cell.width > width && current.length > 0) {
|
|
147
|
+
if (mode === "word" && lastSpace >= 0 && cell.text !== " ") {
|
|
148
|
+
const head = current.slice(0, lastSpace + 1);
|
|
149
|
+
current = current.slice(lastSpace + 1);
|
|
150
|
+
push(head, true);
|
|
151
|
+
used = current.reduce((total, each) => total + each.width, 0);
|
|
152
|
+
}
|
|
153
|
+
lastSpace = -1;
|
|
154
|
+
// A word longer than the whole line breaks mid-word, as it does in a
|
|
155
|
+
// `Text`: the alternative is a line that overflows whatever happens.
|
|
156
|
+
if (used + cell.width > width && current.length > 0) {
|
|
157
|
+
push(current, true);
|
|
158
|
+
current = [];
|
|
159
|
+
used = 0;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
current.push(cell);
|
|
163
|
+
used += cell.width;
|
|
164
|
+
if (cell.text === " ") {
|
|
165
|
+
lastSpace = current.length - 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
push(current, false);
|
|
169
|
+
offset += logical.length + 1;
|
|
170
|
+
}
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Which visual line a string offset is drawn on, and in which column. */
|
|
175
|
+
export function locate(
|
|
176
|
+
lines: $ReadOnlyArray<EditLine>,
|
|
177
|
+
offset: number,
|
|
178
|
+
): { readonly row: number, readonly column: number } {
|
|
179
|
+
// The last line that starts at or before the offset. Two lines of one
|
|
180
|
+
// wrapped paragraph share a boundary — one ends where the next begins — and
|
|
181
|
+
// a cursor on that boundary is drawn at the start of the second, which is
|
|
182
|
+
// where the character it is in front of is.
|
|
183
|
+
let row = 0;
|
|
184
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
185
|
+
if (lines[index].start <= offset) {
|
|
186
|
+
row = index;
|
|
187
|
+
} else {
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
let column = 0;
|
|
192
|
+
for (const cell of lines[row]?.cells ?? []) {
|
|
193
|
+
if (cell.at >= offset) {
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
column += cell.width;
|
|
197
|
+
}
|
|
198
|
+
return { row, column };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The offset on `line` closest to `column`, which is where a cursor moving up
|
|
203
|
+
* or down onto it lands.
|
|
204
|
+
*
|
|
205
|
+
* A column in the middle of a two-column character lands in front of it. A
|
|
206
|
+
* column past the end lands at the end — or, on a line that wraps, in front of
|
|
207
|
+
* its last character, because the end of a wrapped line is drawn on the next.
|
|
208
|
+
*/
|
|
209
|
+
export function offsetAt(line: EditLine, column: number): number {
|
|
210
|
+
let at = 0;
|
|
211
|
+
for (const cell of line.cells) {
|
|
212
|
+
if (at + cell.width > column) {
|
|
213
|
+
return cell.at;
|
|
214
|
+
}
|
|
215
|
+
at += cell.width;
|
|
216
|
+
}
|
|
217
|
+
return lineEnd(line);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The furthest a cursor can go along a visual line and still be drawn on it. */
|
|
221
|
+
export function lineEnd(line: EditLine): number {
|
|
222
|
+
const last = line.cells[line.cells.length - 1];
|
|
223
|
+
return line.wrapped && last != null ? last.at : line.end;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The inside of a node: its border box less its border and its padding.
|
|
228
|
+
*
|
|
229
|
+
* Read by the painter to know where to draw, and by `Textarea` — through the
|
|
230
|
+
* node a ref hands it — to know how wide its lines are when a key moves the
|
|
231
|
+
* cursor up or down. Layout resolved the same four numbers; they are read back
|
|
232
|
+
* off the style rather than kept, because the style is what layout read them
|
|
233
|
+
* from.
|
|
234
|
+
*/
|
|
235
|
+
export function contentBox(node: TuiNode): Rect {
|
|
236
|
+
const style = node.style;
|
|
237
|
+
const all = style.padding ?? 0;
|
|
238
|
+
const top = (style.paddingTop ?? all) + node.borderWidth;
|
|
239
|
+
const right = (style.paddingRight ?? all) + node.borderWidth;
|
|
240
|
+
const bottom = (style.paddingBottom ?? all) + node.borderWidth;
|
|
241
|
+
const left = (style.paddingLeft ?? all) + node.borderWidth;
|
|
242
|
+
return {
|
|
243
|
+
x: node.x + left,
|
|
244
|
+
y: node.y + top,
|
|
245
|
+
width: Math.max(0, node.width - left - right),
|
|
246
|
+
height: Math.max(0, node.height - top - bottom),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A colour prop, or `fallback` when it is absent or unreadable. */
|
|
251
|
+
function colorOf(node: TuiNode, name: string, fallback: Color): Color {
|
|
252
|
+
const raw = node.props[name];
|
|
253
|
+
if (typeof raw === "string" || typeof raw === "number") {
|
|
254
|
+
const parsed = parseColor(raw);
|
|
255
|
+
return parsed === INHERIT && raw !== "transparent" ? fallback : parsed;
|
|
256
|
+
}
|
|
257
|
+
return fallback;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function flag(node: TuiNode, name: string, fallback: boolean): boolean {
|
|
261
|
+
const raw = node.props[name];
|
|
262
|
+
return typeof raw === "boolean" ? raw : fallback;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function count(node: TuiNode, name: string, fallback: number): number {
|
|
266
|
+
const raw = node.props[name];
|
|
267
|
+
return typeof raw === "number" && Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : fallback;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function optionsOf(node: TuiNode): $ReadOnlyArray<SelectOption> {
|
|
271
|
+
const raw = node.props.options;
|
|
272
|
+
// The component typed these; a node is only ever given them by it.
|
|
273
|
+
return Array.isArray(raw) ? (raw as $FlowFixMe) : [];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function selectedOf(node: TuiNode, length: number): number {
|
|
277
|
+
const raw = count(node, "selectedIndex", 0);
|
|
278
|
+
return length > 0 ? Math.min(raw, length - 1) : 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function widthOf(text: string): number {
|
|
282
|
+
let width = 0;
|
|
283
|
+
for (const segment of SEGMENTER.segment(text)) {
|
|
284
|
+
width += graphemeWidth(segment.segment);
|
|
285
|
+
}
|
|
286
|
+
return width;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Write `text` from `x`, one grapheme at a time.
|
|
291
|
+
*
|
|
292
|
+
* `bg` of `undefined` keeps whatever background the cell already has, which is
|
|
293
|
+
* what OpenTUI's `drawText` does when it is given no background: a name drawn
|
|
294
|
+
* over a selected row's fill stays on that fill.
|
|
295
|
+
*/
|
|
296
|
+
function drawText(
|
|
297
|
+
frame: Frame,
|
|
298
|
+
x: number,
|
|
299
|
+
y: number,
|
|
300
|
+
text: string,
|
|
301
|
+
fg: Color,
|
|
302
|
+
bg: Color | void,
|
|
303
|
+
clip: Rect,
|
|
304
|
+
): void {
|
|
305
|
+
let column = x;
|
|
306
|
+
for (const segment of SEGMENTER.segment(text)) {
|
|
307
|
+
const width = graphemeWidth(segment.segment);
|
|
308
|
+
if (width === 0) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
let background = bg;
|
|
312
|
+
if (background === undefined) {
|
|
313
|
+
background =
|
|
314
|
+
column >= 0 && column < frame.width && y >= 0 && y < frame.height
|
|
315
|
+
? frame.bg[y * frame.width + column]
|
|
316
|
+
: INHERIT;
|
|
317
|
+
}
|
|
318
|
+
writeGrapheme(
|
|
319
|
+
frame,
|
|
320
|
+
column,
|
|
321
|
+
y,
|
|
322
|
+
segment.segment,
|
|
323
|
+
width,
|
|
324
|
+
{ fg, bg: background, attributes: Attributes.NONE },
|
|
325
|
+
clip,
|
|
326
|
+
);
|
|
327
|
+
column += width;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* `text`, cut to `max` columns with an ellipsis when it does not fit.
|
|
333
|
+
*
|
|
334
|
+
* OpenTUI counts UTF-16 code units here; this counts columns, which is what
|
|
335
|
+
* "fits" means on a terminal and the same thing for the ASCII OpenTUI's
|
|
336
|
+
* examples use.
|
|
337
|
+
*/
|
|
338
|
+
function truncate(text: string, max: number, ellipsis: string): string {
|
|
339
|
+
if (widthOf(text) <= max) {
|
|
340
|
+
return text;
|
|
341
|
+
}
|
|
342
|
+
let out = "";
|
|
343
|
+
let used = 0;
|
|
344
|
+
for (const segment of SEGMENTER.segment(text)) {
|
|
345
|
+
const width = graphemeWidth(segment.segment);
|
|
346
|
+
if (used + width > max - 1) {
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
out += segment.segment;
|
|
350
|
+
used += width;
|
|
351
|
+
}
|
|
352
|
+
return out + ellipsis;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Rows one `Select` item takes: its name, its description, and the spacing. */
|
|
356
|
+
export function selectLinesPerItem(node: TuiNode): number {
|
|
357
|
+
return (flag(node, "showDescription", true) ? 2 : 1) + count(node, "itemSpacing", 0);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The first item a window of `visible` items shows, with `selected` in it.
|
|
362
|
+
*
|
|
363
|
+
* OpenTUI's rule for both selects: the selection sits in the middle of the
|
|
364
|
+
* window, and the window stops at either end of the list rather than showing
|
|
365
|
+
* empty rows past it.
|
|
366
|
+
*/
|
|
367
|
+
function windowStart(selected: number, visible: number, length: number): number {
|
|
368
|
+
return Math.max(0, Math.min(selected - Math.floor(visible / 2), length - visible));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The size a widget wants when nothing else decides it.
|
|
373
|
+
*
|
|
374
|
+
* A `Select` wants every item — OpenTUI's would be as tall as its style says
|
|
375
|
+
* and no taller, but a box here that is given no height is as tall as its
|
|
376
|
+
* content, and a list that shows all its items is the useful reading of that.
|
|
377
|
+
* Give it a height and it scrolls. A `TabSelect` is always as tall as its rows,
|
|
378
|
+
* as OpenTUI's is. A `Textarea` is as tall as its text, and at least one line.
|
|
379
|
+
*/
|
|
380
|
+
export function measureWidget(
|
|
381
|
+
node: TuiNode,
|
|
382
|
+
widget: Widget,
|
|
383
|
+
availableWidth: number,
|
|
384
|
+
): { readonly width: number, readonly height: number } {
|
|
385
|
+
if (widget === "select") {
|
|
386
|
+
const options = optionsOf(node);
|
|
387
|
+
const indicator = flag(node, "showSelectionIndicator", true) ? 2 : 0;
|
|
388
|
+
let width = 0;
|
|
389
|
+
for (const option of options) {
|
|
390
|
+
width = Math.max(
|
|
391
|
+
width,
|
|
392
|
+
1 + indicator + widthOf(option.name),
|
|
393
|
+
1 + indicator + widthOf(option.description),
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return { width, height: options.length * selectLinesPerItem(node) };
|
|
397
|
+
}
|
|
398
|
+
if (widget === "tab-select") {
|
|
399
|
+
const options = optionsOf(node);
|
|
400
|
+
return {
|
|
401
|
+
width: options.length * Math.max(1, count(node, "tabWidth", DEFAULT_TAB_WIDTH)),
|
|
402
|
+
height: tabSelectHeight(
|
|
403
|
+
flag(node, "showUnderline", true),
|
|
404
|
+
flag(node, "showDescription", true),
|
|
405
|
+
),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const text = typeof node.props.value === "string" ? node.props.value : "";
|
|
409
|
+
const placeholder = typeof node.props.placeholder === "string" ? node.props.placeholder : "";
|
|
410
|
+
const lines = editLines(text === "" ? placeholder : text, availableWidth, wrapOf(node));
|
|
411
|
+
let width = 0;
|
|
412
|
+
for (const line of lines) {
|
|
413
|
+
width = Math.max(width, line.width);
|
|
414
|
+
}
|
|
415
|
+
// One column more than the text, so a cursor after the last character has a
|
|
416
|
+
// cell to stand in rather than being clipped off the edge.
|
|
417
|
+
return { width: width + 1, height: Math.max(1, lines.length) };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** A `TabSelect`'s height: the tabs, the underline, and the description. */
|
|
421
|
+
export function tabSelectHeight(showUnderline: boolean, showDescription: boolean): number {
|
|
422
|
+
return 1 + (showUnderline ? 1 : 0) + (showDescription ? 1 : 0);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function wrapOf(node: TuiNode): EditWrapMode {
|
|
426
|
+
const raw = node.props.wrapMode;
|
|
427
|
+
return raw === "char" || raw === "none" ? raw : "word";
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Draw a widget into the content box of `node`.
|
|
432
|
+
*
|
|
433
|
+
* Called by `paint.js` after the box's own background and border, in place of
|
|
434
|
+
* its children — a widget has none.
|
|
435
|
+
*/
|
|
436
|
+
export function paintWidget(
|
|
437
|
+
node: TuiNode,
|
|
438
|
+
widget: Widget,
|
|
439
|
+
frame: Frame,
|
|
440
|
+
capabilities: Capabilities,
|
|
441
|
+
clip: Rect,
|
|
442
|
+
hits: HitGrid | null,
|
|
443
|
+
selectable: boolean,
|
|
444
|
+
): void {
|
|
445
|
+
const area = contentBox(node);
|
|
446
|
+
const inside = intersect(clip, area);
|
|
447
|
+
const ascii = capabilities.glyphs === "ascii";
|
|
448
|
+
if (widget === "select") {
|
|
449
|
+
paintSelect(node, frame, area, inside, ascii);
|
|
450
|
+
} else if (widget === "tab-select") {
|
|
451
|
+
paintTabSelect(node, frame, area, inside, ascii);
|
|
452
|
+
} else {
|
|
453
|
+
paintTextarea(node, frame, area, inside, hits, selectable);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The background and text colours a widget uses with and without focus. */
|
|
458
|
+
function baseColors(node: TuiNode): { bg: Color, fg: Color } {
|
|
459
|
+
const focused = node.props.focused === true;
|
|
460
|
+
const bg = colorOf(node, "backgroundColor", INHERIT);
|
|
461
|
+
const fg = colorOf(node, "textColor", INHERIT);
|
|
462
|
+
return focused
|
|
463
|
+
? { bg: colorOf(node, "focusedBackgroundColor", bg), fg: colorOf(node, "focusedTextColor", fg) }
|
|
464
|
+
: { bg, fg };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function paintSelect(node: TuiNode, frame: Frame, area: Rect, clip: Rect, ascii: boolean): void {
|
|
468
|
+
const { bg, fg } = baseColors(node);
|
|
469
|
+
if (bg !== INHERIT) {
|
|
470
|
+
fillRect(frame, area, { fg, bg, attributes: Attributes.NONE }, clip);
|
|
471
|
+
}
|
|
472
|
+
const options = optionsOf(node);
|
|
473
|
+
if (options.length === 0) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const selected = selectedOf(node, options.length);
|
|
477
|
+
const spacing = count(node, "itemSpacing", 0);
|
|
478
|
+
const perItem = selectLinesPerItem(node);
|
|
479
|
+
const showDescription = flag(node, "showDescription", true);
|
|
480
|
+
const showIndicator = flag(node, "showSelectionIndicator", true);
|
|
481
|
+
const visible = Math.max(1, Math.floor(area.height / perItem));
|
|
482
|
+
const first = windowStart(selected, visible, options.length);
|
|
483
|
+
const selectedBg = colorOf(node, "selectedBackgroundColor", SELECTED_BACKGROUND);
|
|
484
|
+
const selectedFg = colorOf(node, "selectedTextColor", SELECTED_TEXT);
|
|
485
|
+
const description = colorOf(node, "descriptionColor", DESCRIPTION);
|
|
486
|
+
const selectedDescription = colorOf(node, "selectedDescriptionColor", SELECTED_DESCRIPTION);
|
|
487
|
+
const arrow = ascii ? "> " : "▶ ";
|
|
488
|
+
|
|
489
|
+
for (let slot = 0; slot < visible && first + slot < options.length; slot += 1) {
|
|
490
|
+
const index = first + slot;
|
|
491
|
+
const option = options[index];
|
|
492
|
+
const isSelected = index === selected;
|
|
493
|
+
const top = slot * perItem;
|
|
494
|
+
// An item that would not fit whole is not drawn at all, which is OpenTUI's
|
|
495
|
+
// rule: half a name with no description is not an item a reader can read.
|
|
496
|
+
if (top + perItem - 1 >= area.height) {
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
const y = area.y + top;
|
|
500
|
+
if (isSelected) {
|
|
501
|
+
fillRect(
|
|
502
|
+
frame,
|
|
503
|
+
{ x: area.x, y, width: area.width, height: perItem - spacing },
|
|
504
|
+
{ fg: selectedFg, bg: selectedBg, attributes: Attributes.NONE },
|
|
505
|
+
clip,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
const indicator = showIndicator ? (isSelected ? arrow : " ") : "";
|
|
509
|
+
drawText(
|
|
510
|
+
frame,
|
|
511
|
+
area.x + 1,
|
|
512
|
+
y,
|
|
513
|
+
indicator + option.name,
|
|
514
|
+
isSelected ? selectedFg : fg,
|
|
515
|
+
undefined,
|
|
516
|
+
clip,
|
|
517
|
+
);
|
|
518
|
+
if (showDescription && top + 1 < area.height) {
|
|
519
|
+
drawText(
|
|
520
|
+
frame,
|
|
521
|
+
area.x + 1 + (showIndicator ? 2 : 0),
|
|
522
|
+
y + 1,
|
|
523
|
+
option.description,
|
|
524
|
+
isSelected ? selectedDescription : description,
|
|
525
|
+
undefined,
|
|
526
|
+
clip,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (flag(node, "showScrollIndicator", false) && options.length > visible) {
|
|
532
|
+
const percent = first / (options.length - visible);
|
|
533
|
+
const track = Math.max(1, area.height - 2);
|
|
534
|
+
drawText(
|
|
535
|
+
frame,
|
|
536
|
+
area.x + area.width - 1,
|
|
537
|
+
area.y + 1 + Math.floor(percent * track),
|
|
538
|
+
ascii ? "#" : "█",
|
|
539
|
+
SCROLL_INDICATOR,
|
|
540
|
+
undefined,
|
|
541
|
+
clip,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function paintTabSelect(node: TuiNode, frame: Frame, area: Rect, clip: Rect, ascii: boolean): void {
|
|
547
|
+
const { bg, fg } = baseColors(node);
|
|
548
|
+
if (bg !== INHERIT) {
|
|
549
|
+
fillRect(frame, area, { fg, bg, attributes: Attributes.NONE }, clip);
|
|
550
|
+
}
|
|
551
|
+
const options = optionsOf(node);
|
|
552
|
+
if (options.length === 0) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const selected = selectedOf(node, options.length);
|
|
556
|
+
const tabWidth = Math.max(1, count(node, "tabWidth", DEFAULT_TAB_WIDTH));
|
|
557
|
+
const showUnderline = flag(node, "showUnderline", true);
|
|
558
|
+
const showDescription = flag(node, "showDescription", true);
|
|
559
|
+
const visible = Math.max(1, Math.floor(area.width / tabWidth));
|
|
560
|
+
const first = windowStart(selected, visible, options.length);
|
|
561
|
+
const selectedBg = colorOf(node, "selectedBackgroundColor", SELECTED_BACKGROUND);
|
|
562
|
+
const selectedFg = colorOf(node, "selectedTextColor", SELECTED_TEXT);
|
|
563
|
+
const ellipsis = ascii ? "~" : "…";
|
|
564
|
+
|
|
565
|
+
for (let slot = 0; slot < visible && first + slot < options.length; slot += 1) {
|
|
566
|
+
const index = first + slot;
|
|
567
|
+
const isSelected = index === selected;
|
|
568
|
+
const offset = slot * tabWidth;
|
|
569
|
+
if (offset >= area.width) {
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
const x = area.x + offset;
|
|
573
|
+
const width = Math.min(tabWidth, area.width - offset);
|
|
574
|
+
if (isSelected) {
|
|
575
|
+
fillRect(
|
|
576
|
+
frame,
|
|
577
|
+
{ x, y: area.y, width, height: 1 },
|
|
578
|
+
{ fg: selectedFg, bg: selectedBg, attributes: Attributes.NONE },
|
|
579
|
+
clip,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
const color = isSelected ? selectedFg : fg;
|
|
583
|
+
drawText(
|
|
584
|
+
frame,
|
|
585
|
+
x + 1,
|
|
586
|
+
area.y,
|
|
587
|
+
truncate(options[index].name, width - 2, ellipsis),
|
|
588
|
+
color,
|
|
589
|
+
undefined,
|
|
590
|
+
clip,
|
|
591
|
+
);
|
|
592
|
+
if (isSelected && showUnderline && area.height >= 2) {
|
|
593
|
+
drawText(frame, x, area.y + 1, (ascii ? "=" : "▬").repeat(width), color, selectedBg, clip);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (showDescription && area.height >= (showUnderline ? 3 : 2)) {
|
|
598
|
+
drawText(
|
|
599
|
+
frame,
|
|
600
|
+
area.x + 1,
|
|
601
|
+
area.y + (showUnderline ? 2 : 1),
|
|
602
|
+
truncate(options[selected].description, area.width - 2, ellipsis),
|
|
603
|
+
colorOf(node, "selectedDescriptionColor", SELECTED_DESCRIPTION),
|
|
604
|
+
undefined,
|
|
605
|
+
clip,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (flag(node, "showScrollArrows", true) && options.length > visible) {
|
|
610
|
+
if (first > 0) {
|
|
611
|
+
drawText(frame, area.x, area.y, ascii ? "<" : "‹", SCROLL_ARROWS, undefined, clip);
|
|
612
|
+
}
|
|
613
|
+
if (first + visible < options.length) {
|
|
614
|
+
drawText(
|
|
615
|
+
frame,
|
|
616
|
+
area.x + area.width - 1,
|
|
617
|
+
area.y,
|
|
618
|
+
ascii ? ">" : "›",
|
|
619
|
+
SCROLL_ARROWS,
|
|
620
|
+
undefined,
|
|
621
|
+
clip,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Draw a `Textarea`: its lines, its cursor, and the window onto both.
|
|
629
|
+
*
|
|
630
|
+
* The window is the one piece of state this module keeps, on the node, between
|
|
631
|
+
* frames — `viewTop` and `viewLeft`. It moves only as far as it has to for the
|
|
632
|
+
* cursor to be inside it, which is what every editor does and what OpenTUI's
|
|
633
|
+
* does: a window recomputed from the cursor alone would jump to put the cursor
|
|
634
|
+
* in the same place on every keystroke. It is written here rather than in
|
|
635
|
+
* layout because it is a function of the lines, and the lines are this
|
|
636
|
+
* module's.
|
|
637
|
+
*/
|
|
638
|
+
function paintTextarea(
|
|
639
|
+
node: TuiNode,
|
|
640
|
+
frame: Frame,
|
|
641
|
+
area: Rect,
|
|
642
|
+
clip: Rect,
|
|
643
|
+
hits: HitGrid | null,
|
|
644
|
+
selectable: boolean,
|
|
645
|
+
): void {
|
|
646
|
+
const { bg, fg } = baseColors(node);
|
|
647
|
+
if (bg !== INHERIT) {
|
|
648
|
+
fillRect(frame, area, { fg, bg, attributes: Attributes.NONE }, clip);
|
|
649
|
+
}
|
|
650
|
+
const focused = node.props.focused === true;
|
|
651
|
+
const text = typeof node.props.value === "string" ? node.props.value : "";
|
|
652
|
+
const mode = wrapOf(node);
|
|
653
|
+
|
|
654
|
+
if (text === "") {
|
|
655
|
+
node.viewTop = 0;
|
|
656
|
+
node.viewLeft = 0;
|
|
657
|
+
const placeholder = typeof node.props.placeholder === "string" ? node.props.placeholder : "";
|
|
658
|
+
const color = colorOf(node, "placeholderColor", PLACEHOLDER);
|
|
659
|
+
const lines = editLines(placeholder, area.width, mode);
|
|
660
|
+
for (let row = 0; row < lines.length && row < area.height; row += 1) {
|
|
661
|
+
drawCells(
|
|
662
|
+
frame,
|
|
663
|
+
lines[row].cells,
|
|
664
|
+
area.x,
|
|
665
|
+
area.y + row,
|
|
666
|
+
0,
|
|
667
|
+
{ fg: color, bg, attributes: 0 },
|
|
668
|
+
clip,
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
if (focused) {
|
|
672
|
+
cursorCell(frame, lines[0]?.cells[0], area.x, area.y, fg, bg, clip);
|
|
673
|
+
}
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const cursor = Math.max(
|
|
678
|
+
0,
|
|
679
|
+
Math.min(typeof node.props.cursor === "number" ? node.props.cursor : 0, text.length),
|
|
680
|
+
);
|
|
681
|
+
const lines = editLines(text, area.width, mode);
|
|
682
|
+
const { row, column: rawColumn } = locate(lines, cursor);
|
|
683
|
+
// A cursor after the last character of a line that exactly fills the width
|
|
684
|
+
// has no column to stand in; it stands on that character instead.
|
|
685
|
+
const column =
|
|
686
|
+
mode === "none" || area.width <= 0 ? rawColumn : Math.min(rawColumn, area.width - 1);
|
|
687
|
+
|
|
688
|
+
let top = Math.min(node.viewTop, Math.max(0, lines.length - area.height));
|
|
689
|
+
if (row < top) {
|
|
690
|
+
top = row;
|
|
691
|
+
} else if (row >= top + area.height) {
|
|
692
|
+
top = row - area.height + 1;
|
|
693
|
+
}
|
|
694
|
+
let left = mode === "none" ? node.viewLeft : 0;
|
|
695
|
+
if (column < left) {
|
|
696
|
+
left = column;
|
|
697
|
+
} else if (area.width > 0 && column >= left + area.width) {
|
|
698
|
+
left = column - area.width + 1;
|
|
699
|
+
}
|
|
700
|
+
node.viewTop = Math.max(0, top);
|
|
701
|
+
node.viewLeft = Math.max(0, left);
|
|
702
|
+
|
|
703
|
+
const style: Style = { fg, bg, attributes: Attributes.NONE };
|
|
704
|
+
for (let slot = 0; slot < area.height && node.viewTop + slot < lines.length; slot += 1) {
|
|
705
|
+
const line = lines[node.viewTop + slot];
|
|
706
|
+
const y = area.y + slot;
|
|
707
|
+
drawCells(frame, line.cells, area.x, y, node.viewLeft, style, clip);
|
|
708
|
+
if (hits != null && selectable) {
|
|
709
|
+
let x = area.x - node.viewLeft;
|
|
710
|
+
for (const cell of line.cells) {
|
|
711
|
+
if (cell.width > 0) {
|
|
712
|
+
recordText(hits, node, x, y, cell.width, clip);
|
|
713
|
+
}
|
|
714
|
+
x += cell.width;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (focused) {
|
|
720
|
+
const cells = lines[row]?.cells ?? [];
|
|
721
|
+
let under = cells.find((cell) => cell.at === cursor) ?? null;
|
|
722
|
+
let x = rawColumn;
|
|
723
|
+
const last = cells[cells.length - 1];
|
|
724
|
+
if (under == null && column < rawColumn && last != null) {
|
|
725
|
+
// No column after the line to stand in, so the cursor stands on its
|
|
726
|
+
// last character rather than off the edge of the box.
|
|
727
|
+
under = last;
|
|
728
|
+
x = rawColumn - last.width;
|
|
729
|
+
}
|
|
730
|
+
cursorCell(frame, under, area.x + x - node.viewLeft, area.y + row - node.viewTop, fg, bg, clip);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function drawCells(
|
|
735
|
+
frame: Frame,
|
|
736
|
+
cells: $ReadOnlyArray<EditCell>,
|
|
737
|
+
x: number,
|
|
738
|
+
y: number,
|
|
739
|
+
left: number,
|
|
740
|
+
style: Style,
|
|
741
|
+
clip: Rect,
|
|
742
|
+
): void {
|
|
743
|
+
let column = x - left;
|
|
744
|
+
for (const cell of cells) {
|
|
745
|
+
if (cell.width > 0) {
|
|
746
|
+
writeGrapheme(frame, column, y, cell.text, cell.width, style, clip);
|
|
747
|
+
}
|
|
748
|
+
column += cell.width;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* The cursor: the character under it, or a space, in inverse video.
|
|
754
|
+
*
|
|
755
|
+
* The same choice `Input` makes and for the same reason — a terminal has one
|
|
756
|
+
* real cursor, and an inverse cell is a property of the frame, so two of these
|
|
757
|
+
* on one screen do not fight over it.
|
|
758
|
+
*/
|
|
759
|
+
function cursorCell(
|
|
760
|
+
frame: Frame,
|
|
761
|
+
cell: EditCell | void | null,
|
|
762
|
+
x: number,
|
|
763
|
+
y: number,
|
|
764
|
+
fg: Color,
|
|
765
|
+
bg: Color,
|
|
766
|
+
clip: Rect,
|
|
767
|
+
): void {
|
|
768
|
+
const text = cell != null && cell.width > 0 ? cell.text : " ";
|
|
769
|
+
const width = cell != null && cell.width > 0 ? cell.width : 1;
|
|
770
|
+
writeGrapheme(frame, x, y, text, width, { fg, bg, attributes: Attributes.INVERSE }, clip);
|
|
771
|
+
}
|