@textui/widgets 0.1.0 → 0.3.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 +17 -15
- package/dist/control/checkbox.d.ts +2 -0
- package/dist/control/checkbox.d.ts.map +1 -1
- package/dist/control/checkbox.js +2 -2
- package/dist/control/radio-group.d.ts +2 -0
- package/dist/control/radio-group.d.ts.map +1 -1
- package/dist/control/radio-group.js +2 -2
- package/dist/control/text-area.d.ts +15 -2
- package/dist/control/text-area.d.ts.map +1 -1
- package/dist/control/text-area.js +402 -22
- package/dist/control/text-input.d.ts.map +1 -1
- package/dist/control/text-input.js +40 -0
- package/dist/data/feed.d.ts +14 -0
- package/dist/data/feed.d.ts.map +1 -1
- package/dist/data/feed.js +88 -4
- package/dist/data/list.d.ts +60 -7
- package/dist/data/list.d.ts.map +1 -1
- package/dist/data/list.js +66 -10
- package/dist/data/markdown-view.d.ts.map +1 -1
- package/dist/data/markdown-view.js +49 -0
- package/dist/data/shared.d.ts +18 -1
- package/dist/data/shared.d.ts.map +1 -1
- package/dist/data/shared.js +12 -2
- package/dist/display/color-text.d.ts +158 -0
- package/dist/display/color-text.d.ts.map +1 -0
- package/dist/display/color-text.js +246 -0
- package/dist/display/index.d.ts +1 -0
- package/dist/display/index.d.ts.map +1 -1
- package/dist/display/index.js +3 -0
- package/dist/layout/divider.d.ts +6 -1
- package/dist/layout/divider.d.ts.map +1 -1
- package/dist/layout/divider.js +5 -5
- package/dist/layout/scroll-view.d.ts +10 -0
- package/dist/layout/scroll-view.d.ts.map +1 -1
- package/dist/layout/scroll-view.js +2 -2
- package/dist/navigation/menu.d.ts +24 -0
- package/dist/navigation/menu.d.ts.map +1 -1
- package/dist/navigation/menu.js +58 -15
- package/dist/overlay/command-palette.d.ts +32 -1
- package/dist/overlay/command-palette.d.ts.map +1 -1
- package/dist/overlay/command-palette.js +84 -10
- package/dist/shells/workbench-shell.d.ts.map +1 -1
- package/dist/shells/workbench-shell.js +22 -2
- package/package.json +8 -8
- package/src/control/checkbox.ts +4 -2
- package/src/control/radio-group.ts +4 -2
- package/src/control/text-area.ts +394 -31
- package/src/control/text-input.ts +38 -1
- package/src/data/feed.ts +102 -3
- package/src/data/list.ts +125 -18
- package/src/data/markdown-view.ts +56 -0
- package/src/data/shared.ts +25 -3
- package/src/display/color-text.ts +386 -0
- package/src/display/index.ts +3 -0
- package/src/layout/divider.ts +15 -7
- package/src/layout/scroll-view.ts +12 -2
- package/src/navigation/menu.ts +89 -14
- package/src/overlay/command-palette.ts +125 -11
- package/src/shells/workbench-shell.ts +22 -3
package/src/data/feed.ts
CHANGED
|
@@ -33,6 +33,20 @@ export interface FeedProps extends BoxProps {
|
|
|
33
33
|
onSelect?(index: number): void;
|
|
34
34
|
onActivate?(index: number): void;
|
|
35
35
|
scrollbar?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Who `pageup` and `pagedown` belong to.
|
|
38
|
+
*
|
|
39
|
+
* `focused` is the ordinary answer: the keys go to whatever has the
|
|
40
|
+
* keyboard. `always` claims them even while something else does - for the
|
|
41
|
+
* feed that *is* the screen, with a text field under it. Somebody typing a
|
|
42
|
+
* message who presses page up means the conversation above them; there is
|
|
43
|
+
* nothing else on that screen those keys could be for, and taking the
|
|
44
|
+
* keyboard away from the field to use them is the thing they are avoiding.
|
|
45
|
+
*
|
|
46
|
+
* Only those two keys, and only when the focused node has declined them
|
|
47
|
+
* first - so a field that pages its own content keeps them.
|
|
48
|
+
*/
|
|
49
|
+
pageKeys?: 'focused' | 'always';
|
|
36
50
|
focusable?: boolean;
|
|
37
51
|
autoFocus?: boolean;
|
|
38
52
|
/** So a command can send the reader here by name. */
|
|
@@ -59,6 +73,7 @@ export interface FeedProps extends BoxProps {
|
|
|
59
73
|
export const Feed = defineComponent<FeedProps>('Feed', (props) => {
|
|
60
74
|
const {
|
|
61
75
|
children, follow: followProp, onFollowChange, selectedIndex, onSelect, onActivate,
|
|
76
|
+
pageKeys = 'focused',
|
|
62
77
|
scrollbar = true, focusable = true, autoFocus, focusId, id, ...rest
|
|
63
78
|
} = props;
|
|
64
79
|
|
|
@@ -70,6 +85,20 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
|
|
|
70
85
|
const measured = useMeasure();
|
|
71
86
|
const extent = useScrollExtent();
|
|
72
87
|
const heights = useRef<number[]>([]);
|
|
88
|
+
/**
|
|
89
|
+
* The width those heights were measured at.
|
|
90
|
+
*
|
|
91
|
+
* A height is a statement about a width - what a paragraph wrapped to in a
|
|
92
|
+
* pane this wide - so a resize does not make them stale, it makes them
|
|
93
|
+
* about a layout that no longer exists. Kept and compared rather than
|
|
94
|
+
* ignored, because the alternative is a feed that scrolls to the wrong
|
|
95
|
+
* place for ever after the window is dragged.
|
|
96
|
+
*/
|
|
97
|
+
const heightsAt = useRef(-1);
|
|
98
|
+
/** One handler for every entry, so an entry's props do not change with it. */
|
|
99
|
+
const report = useRef((index: number, height: number): void => {
|
|
100
|
+
heights.current[index] = height;
|
|
101
|
+
});
|
|
73
102
|
|
|
74
103
|
// The catalog's rule, and this has to follow it like every other viewport:
|
|
75
104
|
// given `flex`, a `height`, a `maxHeight` or a `basis`, the layout decided
|
|
@@ -86,7 +115,17 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
|
|
|
86
115
|
const [internalTop, setInternalTop] = useState<number | null>(null);
|
|
87
116
|
const [internalIndex, setInternalIndex] = useState(0);
|
|
88
117
|
|
|
89
|
-
|
|
118
|
+
/*
|
|
119
|
+
* Flattened, because a feed counts entries and JSX groups them.
|
|
120
|
+
*
|
|
121
|
+
* `{caption}{items.map(...)}` arrives as `[caption, [a, b, c]]` - two
|
|
122
|
+
* children, one of which is an array - so a feed of twenty entries with
|
|
123
|
+
* anything written beside the map counted two. The cursor then clamped to
|
|
124
|
+
* the second, every key after the first did nothing, and the scroll never
|
|
125
|
+
* moved. It reads as a dead list and is an arity bug.
|
|
126
|
+
*/
|
|
127
|
+
const entries = (Array.isArray(children) ? children.flat(Infinity) : [children])
|
|
128
|
+
.filter((c) => c != null);
|
|
90
129
|
const count = entries.length;
|
|
91
130
|
const selects = onSelect !== undefined || selectedIndex !== undefined;
|
|
92
131
|
const index = Math.max(0, Math.min(count - 1, selectedIndex ?? internalIndex));
|
|
@@ -166,10 +205,70 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
|
|
|
166
205
|
{ focusId: focus.id, enabled: focusable },
|
|
167
206
|
);
|
|
168
207
|
|
|
208
|
+
/*
|
|
209
|
+
* The page keys, from wherever the keyboard happens to be.
|
|
210
|
+
*
|
|
211
|
+
* `global` handlers run only after the focused node has declined the key,
|
|
212
|
+
* so a field that pages its own content still keeps them - this is the one
|
|
213
|
+
* that catches what nothing else wanted.
|
|
214
|
+
*/
|
|
215
|
+
useInput(
|
|
216
|
+
(event) => {
|
|
217
|
+
if (event.name !== 'pageup' && event.name !== 'pagedown') return false;
|
|
218
|
+
if (chorded(event)) return false;
|
|
219
|
+
const page = Math.max(1, measured.height - 2);
|
|
220
|
+
scrollTo(event.name === 'pageup' ? top - page : top + page);
|
|
221
|
+
return true;
|
|
222
|
+
},
|
|
223
|
+
{ global: true, enabled: pageKeys === 'always' },
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (measured.width !== heightsAt.current) {
|
|
227
|
+
heightsAt.current = measured.width;
|
|
228
|
+
heights.current = [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/*
|
|
232
|
+
* Which entries are far enough away not to be worth drawing.
|
|
233
|
+
*
|
|
234
|
+
* A feed lays out every entry it holds, and laying one out means wrapping
|
|
235
|
+
* its text - so a viewport thirty rows tall over a transcript of six
|
|
236
|
+
* hundred entries spent every frame measuring the five hundred and seventy
|
|
237
|
+
* nobody can see. That cost is paid per *frame*, not per change: a
|
|
238
|
+
* keystroke in a field elsewhere on the screen, a caret blinking, a
|
|
239
|
+
* spinner. It is why a long conversation is heavy to type into and a short
|
|
240
|
+
* one is not.
|
|
241
|
+
*
|
|
242
|
+
* An entry is skipped only once it has been measured, which is what keeps
|
|
243
|
+
* this honest: the first frame draws everything and learns every height,
|
|
244
|
+
* and from then on the ones outside the window are replaced by a box of
|
|
245
|
+
* exactly the height they had. Nothing is estimated, so the extent, the
|
|
246
|
+
* scrollbar and the position of every entry are what they would have been
|
|
247
|
+
* had all of them been drawn.
|
|
248
|
+
*
|
|
249
|
+
* A screenful either side, so scrolling and paging land on something that
|
|
250
|
+
* is already there rather than on a frame of blanks.
|
|
251
|
+
*/
|
|
252
|
+
const view = Math.max(1, measured.height);
|
|
253
|
+
const skipped: boolean[] = [];
|
|
254
|
+
{
|
|
255
|
+
let y = 0;
|
|
256
|
+
for (let i = 0; i < count; i++) {
|
|
257
|
+
const height = heights.current[i];
|
|
258
|
+
// Zero is not a height that has been learned, whatever wrote it: an
|
|
259
|
+
// entry standing in for itself at nothing tall is one that can never
|
|
260
|
+
// be drawn again to find out how tall it is.
|
|
261
|
+
if (height === undefined || height <= 0) { skipped[i] = false; continue; }
|
|
262
|
+
skipped[i] = fills && (y + height < top - view || y > top + view + view);
|
|
263
|
+
y += height;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
169
266
|
const drawn = entries.map((entry, i) => h(FeedEntry, {
|
|
170
267
|
key: i,
|
|
171
|
-
|
|
172
|
-
|
|
268
|
+
index: i,
|
|
269
|
+
onHeight: report.current,
|
|
270
|
+
...(skipped[i] ? { placeholder: true, height: heights.current[i] as number } : {}),
|
|
271
|
+
}, skipped[i] ? null : entry));
|
|
173
272
|
|
|
174
273
|
// Content-sized: draw everything and let the box grow. Clamping to a
|
|
175
274
|
// measurement here would clamp to how tall this happened to be last frame,
|
package/src/data/list.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BoxProps, SemanticVariant } from '@textui/core';
|
|
1
|
+
import type { BoxProps, RenderOutput, SemanticVariant } from '@textui/core';
|
|
2
2
|
import {
|
|
3
3
|
chorded,
|
|
4
4
|
defineComponent,
|
|
@@ -24,15 +24,39 @@ export interface ListItem {
|
|
|
24
24
|
disabled?: boolean;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/** How a row stands at the moment it is asked to draw itself. */
|
|
28
|
+
export interface ListItemState {
|
|
29
|
+
/** The row the selection is on. */
|
|
30
|
+
selected: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* ...and the list itself has the keyboard.
|
|
33
|
+
*
|
|
34
|
+
* Both, and not one: a selected row in a list that has lost focus is a
|
|
35
|
+
* *remembered* choice, and drawing it as loudly as a live one makes two
|
|
36
|
+
* panes look like they both have the cursor.
|
|
37
|
+
*/
|
|
38
|
+
focused: boolean;
|
|
39
|
+
disabled: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ListProps<T extends ListItem = ListItem> extends BoxProps {
|
|
43
|
+
/**
|
|
44
|
+
* The rows.
|
|
45
|
+
*
|
|
46
|
+
* `T` is whatever the caller's own row type is, so long as it is a
|
|
47
|
+
* `ListItem` - which is what the built-in row needs and what `id` being the
|
|
48
|
+
* selection's name needs. Passing plain `ListItem`s is the ordinary case and
|
|
49
|
+
* `T` costs nothing there; a caller with a `renderItem` gets its own fields
|
|
50
|
+
* back on the way in rather than a lookup by id.
|
|
51
|
+
*/
|
|
52
|
+
items: T[];
|
|
29
53
|
selectedId?: string;
|
|
30
|
-
onSelect?(id: string, item:
|
|
31
|
-
onActivate?(id: string, item:
|
|
54
|
+
onSelect?(id: string, item: T): void;
|
|
55
|
+
onActivate?(id: string, item: T): void;
|
|
32
56
|
/** Rows visible at once. Scrolls when there are more. */
|
|
33
57
|
visibleRows?: number;
|
|
34
58
|
emptyMessage?: string;
|
|
35
|
-
/** Draw a marker column for the selected row. */
|
|
59
|
+
/** Draw a marker column for the selected row. Drawn either way. */
|
|
36
60
|
marker?: boolean;
|
|
37
61
|
focusable?: boolean;
|
|
38
62
|
autoFocus?: boolean;
|
|
@@ -42,14 +66,42 @@ export interface ListProps extends BoxProps {
|
|
|
42
66
|
* nothing outside the render can know.
|
|
43
67
|
*/
|
|
44
68
|
focusId?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Draw one row's contents.
|
|
71
|
+
*
|
|
72
|
+
* The built-in row - icon, title, description, meta, on one line - is the
|
|
73
|
+
* shape most catalogues are, and it is what you get by leaving this alone.
|
|
74
|
+
* The moment a caller wants a different one, the repair is *not* another
|
|
75
|
+
* field on `ListItem` and another flag saying where to put it: that road
|
|
76
|
+
* ends with a component whose props are a small layout language, and it
|
|
77
|
+
* still cannot draw the row after next.
|
|
78
|
+
*
|
|
79
|
+
* So the row is the caller's, and everything a row cannot do for itself
|
|
80
|
+
* stays here: the selection, the keys that move it, the window that scrolls,
|
|
81
|
+
* the highlight, the marker column and the click. `state` is what the row
|
|
82
|
+
* cannot know - whether it is the selected one, and whether that selection
|
|
83
|
+
* is live.
|
|
84
|
+
*
|
|
85
|
+
* A row taller than one line has to say so with `itemHeight`.
|
|
86
|
+
*/
|
|
87
|
+
renderItem?(item: T, state: ListItemState): RenderOutput;
|
|
88
|
+
/**
|
|
89
|
+
* Lines one row occupies, when `renderItem` draws more than one.
|
|
90
|
+
*
|
|
91
|
+
* The list scrolls by arithmetic rather than by measurement - it decides how
|
|
92
|
+
* many rows fit *before* anything is drawn, which is the only way a thousand
|
|
93
|
+
* rows cost the same as ten. That arithmetic is in lines, so a row that is
|
|
94
|
+
* two of them has to be declared, not discovered.
|
|
95
|
+
*/
|
|
96
|
+
itemHeight?: number;
|
|
45
97
|
}
|
|
46
98
|
|
|
47
|
-
|
|
99
|
+
function ListView<T extends ListItem>(props: ListProps<T>): RenderOutput {
|
|
48
100
|
const theme = useTheme();
|
|
49
101
|
const {
|
|
50
102
|
items, selectedId, onSelect, onActivate, visibleRows,
|
|
51
103
|
emptyMessage = 'Nothing here', marker = true, focusable = true,
|
|
52
|
-
autoFocus, focusId, ...rest
|
|
104
|
+
autoFocus, focusId, renderItem, itemHeight = 1, ...rest
|
|
53
105
|
} = props;
|
|
54
106
|
|
|
55
107
|
const focus = useFocus({
|
|
@@ -62,7 +114,20 @@ export const List = defineComponent<ListProps>('List', (props) => {
|
|
|
62
114
|
items.find((i) => !i.disabled)?.id ?? null,
|
|
63
115
|
);
|
|
64
116
|
const currentId = selectedId ?? internalId;
|
|
65
|
-
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* How many *rows* fit, when what was measured is lines.
|
|
120
|
+
*
|
|
121
|
+
* `visibleRows` is already a count of rows, so it stands as given. Anything
|
|
122
|
+
* else comes from the space the list was handed, and a two-line row buys
|
|
123
|
+
* half as many of them.
|
|
124
|
+
*/
|
|
125
|
+
const lines = Math.max(1, itemHeight);
|
|
126
|
+
const fit = (content: number): number => {
|
|
127
|
+
if (visibleRows !== undefined) return Math.max(1, visibleRows);
|
|
128
|
+
return Math.max(1, Math.floor(viewportRows(props, measured, content * lines) / lines));
|
|
129
|
+
};
|
|
130
|
+
const pageSize = fit(10);
|
|
66
131
|
const index = Math.max(0, items.findIndex((i) => i.id === currentId));
|
|
67
132
|
|
|
68
133
|
/**
|
|
@@ -112,13 +177,22 @@ export const List = defineComponent<ListProps>('List', (props) => {
|
|
|
112
177
|
// Unstated, the row count comes from the space the list was given rather
|
|
113
178
|
// than from how many items it holds - a thousand-row list must not decide
|
|
114
179
|
// how tall its own pane is.
|
|
115
|
-
const rows =
|
|
180
|
+
const rows = fit(items.length);
|
|
116
181
|
const start = Math.max(0, Math.min(index - Math.floor(rows / 2), items.length - rows));
|
|
117
182
|
const window = items.slice(start, start + rows);
|
|
118
183
|
|
|
119
184
|
return h('box', { id: focus.id, role: 'list', direction: 'column', ...rest },
|
|
120
185
|
...window.map((item) => {
|
|
121
186
|
const active = item.id === currentId;
|
|
187
|
+
const state: ListItemState = {
|
|
188
|
+
selected: active,
|
|
189
|
+
focused: focus.focused,
|
|
190
|
+
disabled: item.disabled === true,
|
|
191
|
+
};
|
|
192
|
+
// `item.id` is the key as well as the selection's name, and deliberately
|
|
193
|
+
// the same string: a row that reconciled under one identity while the
|
|
194
|
+
// selection pointed at another would be a highlight on the wrong row,
|
|
195
|
+
// and no amount of looking at either one would show why.
|
|
122
196
|
return h('box', {
|
|
123
197
|
key: item.id,
|
|
124
198
|
role: 'listitem',
|
|
@@ -126,6 +200,9 @@ export const List = defineComponent<ListProps>('List', (props) => {
|
|
|
126
200
|
selected: active,
|
|
127
201
|
direction: 'row',
|
|
128
202
|
gap: 1,
|
|
203
|
+
// One background over the whole row, however many lines it draws: a
|
|
204
|
+
// highlight that stopped after the first would split the row it is
|
|
205
|
+
// highlighting in two.
|
|
129
206
|
bg: active && focus.focused ? 'selected' : active ? 'active' : undefined,
|
|
130
207
|
fg: item.disabled ? 'disabled' : active && focus.focused ? 'inverted' : undefined,
|
|
131
208
|
onClick: () => {
|
|
@@ -134,16 +211,46 @@ export const List = defineComponent<ListProps>('List', (props) => {
|
|
|
134
211
|
onSelect?.(item.id, item);
|
|
135
212
|
},
|
|
136
213
|
},
|
|
214
|
+
// The marker stays here even when the contents are the caller's. It
|
|
215
|
+
// belongs to the selection rather than to the row, and a column that
|
|
216
|
+
// every row has to remember to draw is a column that goes crooked.
|
|
137
217
|
marker
|
|
138
|
-
? h('text', { content: active ? theme.glyphs.chevronRight : ' ' })
|
|
218
|
+
? h('text', { content: active ? theme.glyphs.chevronRight : ' ', shrink: 0 })
|
|
139
219
|
: null,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// `muted` on a selected background is unreadable.
|
|
144
|
-
item.description ? h('text', { content: item.description, fg: active ? undefined : 'muted', truncate: 'end' }) : null,
|
|
145
|
-
item.meta ? h('text', { content: item.meta, fg: active ? undefined : 'muted' }) : null,
|
|
220
|
+
...(renderItem
|
|
221
|
+
? [h('box', { direction: 'column', flex: 1 }, renderItem(item, state))]
|
|
222
|
+
: defaultRow(item, active)),
|
|
146
223
|
);
|
|
147
224
|
}),
|
|
148
225
|
);
|
|
149
|
-
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const List = defineComponent('List', ListView as (props: ListProps) => RenderOutput) as typeof ListView;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The row you get for free: an icon, a title, an elaboration, a state.
|
|
232
|
+
*
|
|
233
|
+
* One line, because a list of a thousand of them has to cost what a list of
|
|
234
|
+
* ten does, and because most catalogues are exactly this. Anything else is
|
|
235
|
+
* `renderItem`.
|
|
236
|
+
*/
|
|
237
|
+
function defaultRow(item: ListItem, active: boolean): RenderOutput[] {
|
|
238
|
+
return [
|
|
239
|
+
item.icon ? h('text', { content: item.icon, fg: item.tone ? TONE[item.tone] : undefined }) : null,
|
|
240
|
+
h('text', { content: item.label, flex: 1, truncate: 'end' }),
|
|
241
|
+
// The secondary columns keep the row's colour once it is selected;
|
|
242
|
+
// `muted` on a selected background is unreadable.
|
|
243
|
+
//
|
|
244
|
+
// The description yields first, and by a lot: it is the elaboration,
|
|
245
|
+
// and a row reading "brb_fram…" beside a status cut to "waiting on y…"
|
|
246
|
+
// has spent the width on the wrong two things. `meta` gives up none of
|
|
247
|
+
// it - a status is three words at most, and it is the column the row
|
|
248
|
+
// is being scanned for.
|
|
249
|
+
item.description
|
|
250
|
+
? h('text', { content: item.description, fg: active ? undefined : 'muted', truncate: 'end', shrink: 8 })
|
|
251
|
+
: null,
|
|
252
|
+
item.meta
|
|
253
|
+
? h('text', { content: item.meta, fg: active ? undefined : 'muted', shrink: 0 })
|
|
254
|
+
: null,
|
|
255
|
+
] as RenderOutput[];
|
|
256
|
+
}
|
|
@@ -51,6 +51,9 @@ export const MarkdownView = defineComponent<MarkdownViewProps>('MarkdownView', (
|
|
|
51
51
|
bullet: theme.glyphs.bulletFilled,
|
|
52
52
|
quoteBar: theme.borderChars().left,
|
|
53
53
|
ruled,
|
|
54
|
+
// How many rules a table gets is the theme's, but *where* they go is a
|
|
55
|
+
// question about rows, so it is answered in the layout rather than here.
|
|
56
|
+
tableRules: theme.tableRules,
|
|
54
57
|
}),
|
|
55
58
|
[given, content, width, theme, ruled],
|
|
56
59
|
);
|
|
@@ -120,6 +123,59 @@ export const MarkdownView = defineComponent<MarkdownViewProps>('MarkdownView', (
|
|
|
120
123
|
continue;
|
|
121
124
|
}
|
|
122
125
|
|
|
126
|
+
if (row.kind === 'table') {
|
|
127
|
+
/*
|
|
128
|
+
* A table is ruled even where nothing else is.
|
|
129
|
+
*
|
|
130
|
+
* `theme.border` being `none` is a theme saying "do not box things" -
|
|
131
|
+
* panels, fences, dialogs. It is not saying "do not tell these columns
|
|
132
|
+
* apart": the rules in a table are its structure, not its decoration,
|
|
133
|
+
* and without them it is text that happens to line up, with nothing to
|
|
134
|
+
* say where it ended. So a borderless theme still gets a table, drawn
|
|
135
|
+
* in the plainest set there is.
|
|
136
|
+
*
|
|
137
|
+
* Asked for by name rather than taken from `borderChars()`, which is
|
|
138
|
+
* the same request routed through the terminal's own limits: on a
|
|
139
|
+
* console that cannot draw box characters this comes back as `+`, `-`
|
|
140
|
+
* and `|` rather than as something it would print as a row of boxes.
|
|
141
|
+
*/
|
|
142
|
+
const chars = theme.borderChars(theme.border === 'none' ? 'single' : theme.border);
|
|
143
|
+
const fg = 'borderSubtle';
|
|
144
|
+
|
|
145
|
+
if (row.part !== 'head' && row.part !== 'body') {
|
|
146
|
+
const [left, joint, right] =
|
|
147
|
+
row.part === 'top' ? [chars.topLeft, chars.teeTop, chars.topRight]
|
|
148
|
+
: row.part === 'bottom' ? [chars.bottomLeft, chars.teeBottom, chars.bottomRight]
|
|
149
|
+
: [chars.teeRight, chars.cross, chars.teeLeft];
|
|
150
|
+
const bar = row.part === 'bottom' ? chars.bottom : chars.top;
|
|
151
|
+
// Every column plus the space either side of it, which is what the
|
|
152
|
+
// cell rows spend - an edge measured off the cells alone is an edge
|
|
153
|
+
// two cells short per column.
|
|
154
|
+
out.push(h('text', {
|
|
155
|
+
key,
|
|
156
|
+
content: left
|
|
157
|
+
+ row.widths.map((w) => repeatToWidth(bar, w + 2)).join(joint)
|
|
158
|
+
+ right,
|
|
159
|
+
fg,
|
|
160
|
+
wrap: 'none',
|
|
161
|
+
}));
|
|
162
|
+
i++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The cells arrive padded to their column, so nothing here measures
|
|
167
|
+
// anything: a row is its cells with a rule between each pair.
|
|
168
|
+
out.push(h('box', { key, direction: 'row', overflow: 'hidden' },
|
|
169
|
+
...row.cells.flatMap((cell, c) => [
|
|
170
|
+
h('text', { key: `s${c}`, content: c === 0 ? `${chars.left} ` : ` ${chars.left} `, fg, wrap: 'none' }),
|
|
171
|
+
h('box', { key: `c${c}`, direction: 'row', overflow: 'hidden' },
|
|
172
|
+
...runNodes(cell, quiet ? { fg: 'muted' as StyleColor } : {})),
|
|
173
|
+
]),
|
|
174
|
+
h('text', { key: 'end', content: ` ${chars.right}`, fg, wrap: 'none' })));
|
|
175
|
+
i++;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
123
179
|
if (row.kind === 'rule') {
|
|
124
180
|
out.push(h('box', { key, height: 1, fill: theme.borderChars().top, fg: 'borderSubtle' }));
|
|
125
181
|
i++;
|
package/src/data/shared.ts
CHANGED
|
@@ -6,13 +6,35 @@ import { defineComponent, h, useEffect, useMeasure, useTheme } from '@textui/cor
|
|
|
6
6
|
*
|
|
7
7
|
* Through a ref rather than state, deliberately: a height arriving must not
|
|
8
8
|
* schedule a render, or every measurement would cause the next measurement.
|
|
9
|
+
*
|
|
10
|
+
* It reports its `index` to one shared handler rather than taking a closure
|
|
11
|
+
* that already knows it. A closure per entry is a new prop on every entry
|
|
12
|
+
* every time the feed itself re-renders, and a changed prop is a re-render -
|
|
13
|
+
* so a feed that redrew for its own reasons dragged every entry it holds
|
|
14
|
+
* along with it, however little any of them had changed.
|
|
9
15
|
*/
|
|
10
|
-
export const FeedEntry = defineComponent<BoxProps & {
|
|
16
|
+
export const FeedEntry = defineComponent<BoxProps & {
|
|
17
|
+
index: number;
|
|
18
|
+
/**
|
|
19
|
+
* Standing in for an entry that is not drawn, at the height it had.
|
|
20
|
+
*
|
|
21
|
+
* A stand-in must not answer the question it is an answer to. Its height is
|
|
22
|
+
* one the feed already knows - it is where it came from - and reporting it
|
|
23
|
+
* back is how a measurement gets to depend on itself: a round where a
|
|
24
|
+
* stand-in came out any shorter would teach the feed that it *was* shorter,
|
|
25
|
+
* and the next round would place the rest of them on top of each other.
|
|
26
|
+
*/
|
|
27
|
+
placeholder?: boolean;
|
|
28
|
+
onHeight(index: number, height: number): void;
|
|
29
|
+
}>(
|
|
11
30
|
'FeedEntry',
|
|
12
31
|
(props) => {
|
|
13
|
-
const { onHeight, children, ...rest } = props;
|
|
32
|
+
const { index, placeholder, onHeight, children, ...rest } = props;
|
|
14
33
|
const measured = useMeasure();
|
|
15
|
-
useEffect(() => {
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (placeholder) return;
|
|
36
|
+
onHeight(index, measured.height);
|
|
37
|
+
}, [measured.height, placeholder]);
|
|
16
38
|
return h('box', { direction: 'column', ...rest }, children);
|
|
17
39
|
},
|
|
18
40
|
);
|