@textui/widgets 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/control/select.d.ts.map +1 -1
- package/dist/control/select.js +10 -0
- package/dist/data/feed.d.ts.map +1 -1
- package/dist/data/feed.js +112 -6
- package/dist/data/shared.d.ts +8 -2
- package/dist/data/shared.d.ts.map +1 -1
- package/dist/data/shared.js +11 -7
- package/dist/shells/workbench-shell.d.ts.map +1 -1
- package/dist/shells/workbench-shell.js +6 -0
- package/package.json +2 -2
- package/src/control/select.ts +10 -0
- package/src/data/feed.ts +108 -6
- package/src/data/shared.ts +15 -9
- package/src/shells/workbench-shell.ts +6 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"select.d.ts","sourceRoot":"","sources":["../../src/control/select.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAiB,YAAY,EAAE,MAAM,cAAc,CAAC;AAK1E,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ;IAC3C,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,IAAI,CAAC,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,CAAC;CACxC;AAED,eAAO,MAAM,MAAM,
|
|
1
|
+
{"version":3,"file":"select.d.ts","sourceRoot":"","sources":["../../src/control/select.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAiB,YAAY,EAAE,MAAM,cAAc,CAAC;AAK1E,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ;IAC3C,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,IAAI,CAAC,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,CAAC;CACxC;AAED,eAAO,MAAM,MAAM,sCAiMjB,CAAC"}
|
package/dist/control/select.js
CHANGED
|
@@ -146,6 +146,16 @@ export const Select = defineComponent('Select', (props) => {
|
|
|
146
146
|
direction: 'column',
|
|
147
147
|
border: { style: theme.border, color: focus.focused ? 'focus' : 'border' },
|
|
148
148
|
padding: [0, 1],
|
|
149
|
+
/*
|
|
150
|
+
* The terminal cursor, parked at the start of the value.
|
|
151
|
+
*
|
|
152
|
+
* There is nothing to type here, so this is not a caret - it is the
|
|
153
|
+
* strongest "you are here" a terminal has, and a control that gives it up
|
|
154
|
+
* on focus is one whose only remaining signal is the colour of its border.
|
|
155
|
+
* Tabbing from a text field used to look like the focus had stayed behind
|
|
156
|
+
* in the field, because the cursor had.
|
|
157
|
+
*/
|
|
158
|
+
cursor: focus.focused ? 0 : undefined,
|
|
149
159
|
...rest,
|
|
150
160
|
}, h('box', {
|
|
151
161
|
direction: 'row',
|
package/dist/data/feed.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feed.d.ts","sourceRoot":"","sources":["../../src/data/feed.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"feed.d.ts","sourceRoot":"","sources":["../../src/data/feed.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAkB7C,MAAM,WAAW,SAAU,SAAQ,QAAQ;IACzC,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,cAAc,CAAC,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC;IACvC;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,UAAU,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IAChC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,IAAI,2DAmRf,CAAC"}
|
package/dist/data/feed.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chorded, defineComponent, h, useFocus, useInput, useMeasure, useRef, useScrollExtent, useState, } from '@textui/core';
|
|
2
2
|
import { sizedByLayout } from '../viewport.js';
|
|
3
|
-
import { FeedEntry
|
|
3
|
+
import { FeedEntry } from './shared.js';
|
|
4
|
+
import { ScrollThumb } from './scroll-thumb.js';
|
|
4
5
|
/**
|
|
5
6
|
* A viewport over entries of any height.
|
|
6
7
|
*
|
|
@@ -28,6 +29,20 @@ export const Feed = defineComponent('Feed', (props) => {
|
|
|
28
29
|
const measured = useMeasure();
|
|
29
30
|
const extent = useScrollExtent();
|
|
30
31
|
const heights = useRef([]);
|
|
32
|
+
/**
|
|
33
|
+
* The width those heights were measured at.
|
|
34
|
+
*
|
|
35
|
+
* A height is a statement about a width - what a paragraph wrapped to in a
|
|
36
|
+
* pane this wide - so a resize does not make them stale, it makes them
|
|
37
|
+
* about a layout that no longer exists. Kept and compared rather than
|
|
38
|
+
* ignored, because the alternative is a feed that scrolls to the wrong
|
|
39
|
+
* place for ever after the window is dragged.
|
|
40
|
+
*/
|
|
41
|
+
const heightsAt = useRef(-1);
|
|
42
|
+
/** One handler for every entry, so an entry's props do not change with it. */
|
|
43
|
+
const report = useRef((index, height) => {
|
|
44
|
+
heights.current[index] = height;
|
|
45
|
+
});
|
|
31
46
|
// The catalog's rule, and this has to follow it like every other viewport:
|
|
32
47
|
// given `flex`, a `height`, a `maxHeight` or a `basis`, the layout decided
|
|
33
48
|
// the size and the feed fills it and scrolls; given none of those, the
|
|
@@ -169,10 +184,88 @@ export const Feed = defineComponent('Feed', (props) => {
|
|
|
169
184
|
scrollTo(event.name === 'pageup' ? top - page : top + page);
|
|
170
185
|
return true;
|
|
171
186
|
}, { global: true, enabled: pageKeys === 'always' });
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
187
|
+
if (measured.width !== heightsAt.current) {
|
|
188
|
+
heightsAt.current = measured.width;
|
|
189
|
+
heights.current = [];
|
|
190
|
+
}
|
|
191
|
+
/*
|
|
192
|
+
* Which entries are far enough away not to be worth drawing.
|
|
193
|
+
*
|
|
194
|
+
* A feed lays out every entry it holds, and laying one out means wrapping
|
|
195
|
+
* its text - so a viewport thirty rows tall over a transcript of six
|
|
196
|
+
* hundred entries spent every frame measuring the five hundred and seventy
|
|
197
|
+
* nobody can see. That cost is paid per *frame*, not per change: a
|
|
198
|
+
* keystroke in a field elsewhere on the screen, a caret blinking, a
|
|
199
|
+
* spinner. It is why a long conversation is heavy to type into and a short
|
|
200
|
+
* one is not.
|
|
201
|
+
*
|
|
202
|
+
* An entry is skipped only once it has been measured, which is what keeps
|
|
203
|
+
* this honest: the first frame draws everything and learns every height,
|
|
204
|
+
* and from then on the ones outside the window are replaced by a box of
|
|
205
|
+
* exactly the height they had. Nothing is estimated, so the extent, the
|
|
206
|
+
* scrollbar and the position of every entry are what they would have been
|
|
207
|
+
* had all of them been drawn.
|
|
208
|
+
*
|
|
209
|
+
* A screenful either side, so scrolling and paging land on something that
|
|
210
|
+
* is already there rather than on a frame of blanks.
|
|
211
|
+
*/
|
|
212
|
+
const view = Math.max(1, measured.height);
|
|
213
|
+
const skipped = [];
|
|
214
|
+
{
|
|
215
|
+
let y = 0;
|
|
216
|
+
for (let i = 0; i < count; i++) {
|
|
217
|
+
const height = heights.current[i];
|
|
218
|
+
// Zero is not a height that has been learned, whatever wrote it: an
|
|
219
|
+
// entry standing in for itself at nothing tall is one that can never
|
|
220
|
+
// be drawn again to find out how tall it is.
|
|
221
|
+
if (height === undefined || height <= 0) {
|
|
222
|
+
skipped[i] = false;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
skipped[i] = fills && (y + height < top - view || y > top + view + view);
|
|
226
|
+
y += height;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/*
|
|
230
|
+
* A run of skipped entries is one box, not one box each.
|
|
231
|
+
*
|
|
232
|
+
* Skipping the *contents* of an entry took the wrapping away, which was the
|
|
233
|
+
* expensive part, and left everything else: an entry standing in for itself
|
|
234
|
+
* is still a component to reconcile and still a box to measure, four times a
|
|
235
|
+
* frame, at every level of the tree above it. Six hundred entries with
|
|
236
|
+
* thirty on screen was five hundred and seventy of those, and they cost the
|
|
237
|
+
* same whether or not anything in them had changed.
|
|
238
|
+
*
|
|
239
|
+
* The window is contiguous, so there are at most two runs - everything above
|
|
240
|
+
* it and everything below - and each collapses to a single box of exactly
|
|
241
|
+
* the height its entries had. The entries that *are* drawn keep their own
|
|
242
|
+
* index as their key, so scrolling moves the boundary without disturbing the
|
|
243
|
+
* identity of anything still on screen.
|
|
244
|
+
*/
|
|
245
|
+
const drawn = [];
|
|
246
|
+
let gap = 0;
|
|
247
|
+
let gapFrom = 0;
|
|
248
|
+
const closeGap = () => {
|
|
249
|
+
if (gap <= 0)
|
|
250
|
+
return;
|
|
251
|
+
drawn.push(h('box', { key: `gap:${String(gapFrom)}`, height: gap }));
|
|
252
|
+
gap = 0;
|
|
253
|
+
};
|
|
254
|
+
for (let i = 0; i < count; i++) {
|
|
255
|
+
if (skipped[i]) {
|
|
256
|
+
if (gap === 0)
|
|
257
|
+
gapFrom = i;
|
|
258
|
+
gap += heights.current[i];
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
closeGap();
|
|
262
|
+
drawn.push(h(FeedEntry, {
|
|
263
|
+
key: i,
|
|
264
|
+
index: i,
|
|
265
|
+
onHeight: report.current,
|
|
266
|
+
}, entries[i]));
|
|
267
|
+
}
|
|
268
|
+
closeGap();
|
|
176
269
|
// Content-sized: draw everything and let the box grow. Clamping to a
|
|
177
270
|
// measurement here would clamp to how tall this happened to be last frame,
|
|
178
271
|
// which for a box sized *by* its own content is nothing at all - the entries
|
|
@@ -198,7 +291,20 @@ export const Feed = defineComponent('Feed', (props) => {
|
|
|
198
291
|
return true;
|
|
199
292
|
},
|
|
200
293
|
}, h('box', { flex: 1, direction: 'column', scrollTop: top, overflow: 'scroll' }, ...drawn),
|
|
294
|
+
// `ScrollThumb`, the one every other viewport in the library draws. This
|
|
295
|
+
// one had its own: a column filled with the border glyph, taking no offset
|
|
296
|
+
// and saying nothing about where in the conversation you were or how much
|
|
297
|
+
// of it there is. Against the panel it sits inside, it read as a second
|
|
298
|
+
// border - and a bar that cannot be told from a frame is not a bar.
|
|
299
|
+
//
|
|
201
300
|
// Only when there is somewhere to scroll. A track down the side of a feed
|
|
202
301
|
// that fits is chrome that states something untrue.
|
|
203
|
-
scrollbar && limit > 0
|
|
302
|
+
scrollbar && limit > 0
|
|
303
|
+
? h(ScrollThumb, {
|
|
304
|
+
total: total || measured.height,
|
|
305
|
+
rows: measured.height,
|
|
306
|
+
offset: top,
|
|
307
|
+
focused: focus.focused,
|
|
308
|
+
})
|
|
309
|
+
: null);
|
|
204
310
|
});
|
package/dist/data/shared.d.ts
CHANGED
|
@@ -4,9 +4,15 @@ import type { BoxProps } from '@textui/core';
|
|
|
4
4
|
*
|
|
5
5
|
* Through a ref rather than state, deliberately: a height arriving must not
|
|
6
6
|
* schedule a render, or every measurement would cause the next measurement.
|
|
7
|
+
*
|
|
8
|
+
* It reports its `index` to one shared handler rather than taking a closure
|
|
9
|
+
* that already knows it. A closure per entry is a new prop on every entry
|
|
10
|
+
* every time the feed itself re-renders, and a changed prop is a re-render -
|
|
11
|
+
* so a feed that redrew for its own reasons dragged every entry it holds
|
|
12
|
+
* along with it, however little any of them had changed.
|
|
7
13
|
*/
|
|
8
14
|
export declare const FeedEntry: (props: BoxProps & {
|
|
9
|
-
|
|
15
|
+
index: number;
|
|
16
|
+
onHeight(index: number, height: number): void;
|
|
10
17
|
}) => import("@textui/core").RenderOutput;
|
|
11
|
-
export declare const FeedScrollbar: (props: Record<string, never>) => import("@textui/core").RenderOutput;
|
|
12
18
|
//# sourceMappingURL=shared.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/data/shared.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAG7C
|
|
1
|
+
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/data/shared.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAG7C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,SAAS;WACb,MAAM;oBACG,MAAM,UAAU,MAAM,GAAG,IAAI;yCAW9C,CAAC"}
|
package/dist/data/shared.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
-
import { defineComponent, h, useEffect, useMeasure
|
|
1
|
+
import { defineComponent, h, useEffect, useMeasure } from '@textui/core';
|
|
2
2
|
/**
|
|
3
3
|
* An entry that reports how tall it turned out to be.
|
|
4
4
|
*
|
|
5
5
|
* Through a ref rather than state, deliberately: a height arriving must not
|
|
6
6
|
* schedule a render, or every measurement would cause the next measurement.
|
|
7
|
+
*
|
|
8
|
+
* It reports its `index` to one shared handler rather than taking a closure
|
|
9
|
+
* that already knows it. A closure per entry is a new prop on every entry
|
|
10
|
+
* every time the feed itself re-renders, and a changed prop is a re-render -
|
|
11
|
+
* so a feed that redrew for its own reasons dragged every entry it holds
|
|
12
|
+
* along with it, however little any of them had changed.
|
|
7
13
|
*/
|
|
8
14
|
export const FeedEntry = defineComponent('FeedEntry', (props) => {
|
|
9
|
-
const { onHeight, children, ...rest } = props;
|
|
15
|
+
const { index, onHeight, children, ...rest } = props;
|
|
10
16
|
const measured = useMeasure();
|
|
11
|
-
useEffect(() => {
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
onHeight(index, measured.height);
|
|
19
|
+
}, [measured.height]);
|
|
12
20
|
return h('box', { direction: 'column', ...rest }, children);
|
|
13
21
|
});
|
|
14
|
-
export const FeedScrollbar = defineComponent('FeedScrollbar', () => {
|
|
15
|
-
const theme = useTheme();
|
|
16
|
-
return h('box', { width: 1, fill: theme.borderChars().left, fg: 'borderSubtle' });
|
|
17
|
-
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workbench-shell.d.ts","sourceRoot":"","sources":["../../src/shells/workbench-shell.ts"],"names":[],"mappings":"AAMA;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,
|
|
1
|
+
{"version":3,"file":"workbench-shell.d.ts","sourceRoot":"","sources":["../../src/shells/workbench-shell.ts"],"names":[],"mappings":"AAMA;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,iFAmEzB,CAAC"}
|
|
@@ -30,6 +30,12 @@ export const WorkbenchShell = defineComponent('WorkbenchShell', (props) => {
|
|
|
30
30
|
}, h(SurfaceArea, { surface: 'header' }), h('box', { direction: 'row', flex: 1 }, h(SurfaceArea, { surface: 'rail' }), showSidebar
|
|
31
31
|
? h('box', {
|
|
32
32
|
width: 24,
|
|
33
|
+
// Rigid. Sideways, the layout shrinks a child with a plain `width`
|
|
34
|
+
// before it clips anything - which is how terminals have always
|
|
35
|
+
// narrowed, and wrong for chrome: an overflowing pane beside it
|
|
36
|
+
// crushed twenty-four columns of tree down to four. A sidebar is a
|
|
37
|
+
// decision about the window, not a column of the document.
|
|
38
|
+
shrink: 0,
|
|
33
39
|
border: { style: theme.border, sides: { right: true } },
|
|
34
40
|
direction: 'column',
|
|
35
41
|
padding: { left: 1 },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@textui/widgets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The TextUI component catalog - layout, display, controls, data, overlays and charts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"terminal",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"test": "vitest run"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@textui/core": "^0.
|
|
49
|
+
"@textui/core": "^0.4.0"
|
|
50
50
|
},
|
|
51
51
|
"publishConfig": {
|
|
52
52
|
"access": "public"
|
package/src/control/select.ts
CHANGED
|
@@ -202,6 +202,16 @@ export const Select = defineComponent<SelectProps>('Select', (props) => {
|
|
|
202
202
|
direction: 'column',
|
|
203
203
|
border: { style: theme.border, color: focus.focused ? 'focus' : 'border' },
|
|
204
204
|
padding: [0, 1],
|
|
205
|
+
/*
|
|
206
|
+
* The terminal cursor, parked at the start of the value.
|
|
207
|
+
*
|
|
208
|
+
* There is nothing to type here, so this is not a caret - it is the
|
|
209
|
+
* strongest "you are here" a terminal has, and a control that gives it up
|
|
210
|
+
* on focus is one whose only remaining signal is the colour of its border.
|
|
211
|
+
* Tabbing from a text field used to look like the focus had stayed behind
|
|
212
|
+
* in the field, because the cursor had.
|
|
213
|
+
*/
|
|
214
|
+
cursor: focus.focused ? 0 : undefined,
|
|
205
215
|
...rest,
|
|
206
216
|
},
|
|
207
217
|
h('box', {
|
package/src/data/feed.ts
CHANGED
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
useState,
|
|
12
12
|
} from '@textui/core';
|
|
13
13
|
import { sizedByLayout } from '../viewport.js';
|
|
14
|
-
import { FeedEntry
|
|
14
|
+
import { FeedEntry } from './shared.js';
|
|
15
|
+
import { ScrollThumb } from './scroll-thumb.js';
|
|
15
16
|
|
|
16
17
|
// ---------------------------------------------------------------------- feed
|
|
17
18
|
|
|
@@ -85,6 +86,20 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
|
|
|
85
86
|
const measured = useMeasure();
|
|
86
87
|
const extent = useScrollExtent();
|
|
87
88
|
const heights = useRef<number[]>([]);
|
|
89
|
+
/**
|
|
90
|
+
* The width those heights were measured at.
|
|
91
|
+
*
|
|
92
|
+
* A height is a statement about a width - what a paragraph wrapped to in a
|
|
93
|
+
* pane this wide - so a resize does not make them stale, it makes them
|
|
94
|
+
* about a layout that no longer exists. Kept and compared rather than
|
|
95
|
+
* ignored, because the alternative is a feed that scrolls to the wrong
|
|
96
|
+
* place for ever after the window is dragged.
|
|
97
|
+
*/
|
|
98
|
+
const heightsAt = useRef(-1);
|
|
99
|
+
/** One handler for every entry, so an entry's props do not change with it. */
|
|
100
|
+
const report = useRef((index: number, height: number): void => {
|
|
101
|
+
heights.current[index] = height;
|
|
102
|
+
});
|
|
88
103
|
|
|
89
104
|
// The catalog's rule, and this has to follow it like every other viewport:
|
|
90
105
|
// given `flex`, a `height`, a `maxHeight` or a `basis`, the layout decided
|
|
@@ -209,10 +224,84 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
|
|
|
209
224
|
{ global: true, enabled: pageKeys === 'always' },
|
|
210
225
|
);
|
|
211
226
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
227
|
+
if (measured.width !== heightsAt.current) {
|
|
228
|
+
heightsAt.current = measured.width;
|
|
229
|
+
heights.current = [];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/*
|
|
233
|
+
* Which entries are far enough away not to be worth drawing.
|
|
234
|
+
*
|
|
235
|
+
* A feed lays out every entry it holds, and laying one out means wrapping
|
|
236
|
+
* its text - so a viewport thirty rows tall over a transcript of six
|
|
237
|
+
* hundred entries spent every frame measuring the five hundred and seventy
|
|
238
|
+
* nobody can see. That cost is paid per *frame*, not per change: a
|
|
239
|
+
* keystroke in a field elsewhere on the screen, a caret blinking, a
|
|
240
|
+
* spinner. It is why a long conversation is heavy to type into and a short
|
|
241
|
+
* one is not.
|
|
242
|
+
*
|
|
243
|
+
* An entry is skipped only once it has been measured, which is what keeps
|
|
244
|
+
* this honest: the first frame draws everything and learns every height,
|
|
245
|
+
* and from then on the ones outside the window are replaced by a box of
|
|
246
|
+
* exactly the height they had. Nothing is estimated, so the extent, the
|
|
247
|
+
* scrollbar and the position of every entry are what they would have been
|
|
248
|
+
* had all of them been drawn.
|
|
249
|
+
*
|
|
250
|
+
* A screenful either side, so scrolling and paging land on something that
|
|
251
|
+
* is already there rather than on a frame of blanks.
|
|
252
|
+
*/
|
|
253
|
+
const view = Math.max(1, measured.height);
|
|
254
|
+
const skipped: boolean[] = [];
|
|
255
|
+
{
|
|
256
|
+
let y = 0;
|
|
257
|
+
for (let i = 0; i < count; i++) {
|
|
258
|
+
const height = heights.current[i];
|
|
259
|
+
// Zero is not a height that has been learned, whatever wrote it: an
|
|
260
|
+
// entry standing in for itself at nothing tall is one that can never
|
|
261
|
+
// be drawn again to find out how tall it is.
|
|
262
|
+
if (height === undefined || height <= 0) { skipped[i] = false; continue; }
|
|
263
|
+
skipped[i] = fills && (y + height < top - view || y > top + view + view);
|
|
264
|
+
y += height;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/*
|
|
268
|
+
* A run of skipped entries is one box, not one box each.
|
|
269
|
+
*
|
|
270
|
+
* Skipping the *contents* of an entry took the wrapping away, which was the
|
|
271
|
+
* expensive part, and left everything else: an entry standing in for itself
|
|
272
|
+
* is still a component to reconcile and still a box to measure, four times a
|
|
273
|
+
* frame, at every level of the tree above it. Six hundred entries with
|
|
274
|
+
* thirty on screen was five hundred and seventy of those, and they cost the
|
|
275
|
+
* same whether or not anything in them had changed.
|
|
276
|
+
*
|
|
277
|
+
* The window is contiguous, so there are at most two runs - everything above
|
|
278
|
+
* it and everything below - and each collapses to a single box of exactly
|
|
279
|
+
* the height its entries had. The entries that *are* drawn keep their own
|
|
280
|
+
* index as their key, so scrolling moves the boundary without disturbing the
|
|
281
|
+
* identity of anything still on screen.
|
|
282
|
+
*/
|
|
283
|
+
const drawn: unknown[] = [];
|
|
284
|
+
let gap = 0;
|
|
285
|
+
let gapFrom = 0;
|
|
286
|
+
const closeGap = (): void => {
|
|
287
|
+
if (gap <= 0) return;
|
|
288
|
+
drawn.push(h('box', { key: `gap:${String(gapFrom)}`, height: gap }));
|
|
289
|
+
gap = 0;
|
|
290
|
+
};
|
|
291
|
+
for (let i = 0; i < count; i++) {
|
|
292
|
+
if (skipped[i]) {
|
|
293
|
+
if (gap === 0) gapFrom = i;
|
|
294
|
+
gap += heights.current[i] as number;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
closeGap();
|
|
298
|
+
drawn.push(h(FeedEntry, {
|
|
299
|
+
key: i,
|
|
300
|
+
index: i,
|
|
301
|
+
onHeight: report.current,
|
|
302
|
+
}, entries[i]));
|
|
303
|
+
}
|
|
304
|
+
closeGap();
|
|
216
305
|
|
|
217
306
|
// Content-sized: draw everything and let the box grow. Clamping to a
|
|
218
307
|
// measurement here would clamp to how tall this happened to be last frame,
|
|
@@ -240,8 +329,21 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
|
|
|
240
329
|
},
|
|
241
330
|
},
|
|
242
331
|
h('box', { flex: 1, direction: 'column', scrollTop: top, overflow: 'scroll' }, ...drawn),
|
|
332
|
+
// `ScrollThumb`, the one every other viewport in the library draws. This
|
|
333
|
+
// one had its own: a column filled with the border glyph, taking no offset
|
|
334
|
+
// and saying nothing about where in the conversation you were or how much
|
|
335
|
+
// of it there is. Against the panel it sits inside, it read as a second
|
|
336
|
+
// border - and a bar that cannot be told from a frame is not a bar.
|
|
337
|
+
//
|
|
243
338
|
// Only when there is somewhere to scroll. A track down the side of a feed
|
|
244
339
|
// that fits is chrome that states something untrue.
|
|
245
|
-
scrollbar && limit > 0
|
|
340
|
+
scrollbar && limit > 0
|
|
341
|
+
? h(ScrollThumb, {
|
|
342
|
+
total: total || measured.height,
|
|
343
|
+
rows: measured.height,
|
|
344
|
+
offset: top,
|
|
345
|
+
focused: focus.focused,
|
|
346
|
+
})
|
|
347
|
+
: null,
|
|
246
348
|
);
|
|
247
349
|
});
|
package/src/data/shared.ts
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
import type { BoxProps } from '@textui/core';
|
|
2
|
-
import { defineComponent, h, useEffect, useMeasure
|
|
2
|
+
import { defineComponent, h, useEffect, useMeasure } from '@textui/core';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* An entry that reports how tall it turned out to be.
|
|
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
|
+
onHeight(index: number, height: number): void;
|
|
19
|
+
}>(
|
|
11
20
|
'FeedEntry',
|
|
12
21
|
(props) => {
|
|
13
|
-
const { onHeight, children, ...rest } = props;
|
|
22
|
+
const { index, onHeight, children, ...rest } = props;
|
|
14
23
|
const measured = useMeasure();
|
|
15
|
-
useEffect(() => {
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
onHeight(index, measured.height);
|
|
26
|
+
}, [measured.height]);
|
|
16
27
|
return h('box', { direction: 'column', ...rest }, children);
|
|
17
28
|
},
|
|
18
29
|
);
|
|
19
|
-
|
|
20
|
-
export const FeedScrollbar = defineComponent<Record<string, never>>('FeedScrollbar', () => {
|
|
21
|
-
const theme = useTheme();
|
|
22
|
-
return h('box', { width: 1, fill: theme.borderChars().left, fg: 'borderSubtle' });
|
|
23
|
-
});
|
|
@@ -39,6 +39,12 @@ export const WorkbenchShell = defineComponent<ShellProps>('WorkbenchShell', (pro
|
|
|
39
39
|
showSidebar
|
|
40
40
|
? h('box', {
|
|
41
41
|
width: 24,
|
|
42
|
+
// Rigid. Sideways, the layout shrinks a child with a plain `width`
|
|
43
|
+
// before it clips anything - which is how terminals have always
|
|
44
|
+
// narrowed, and wrong for chrome: an overflowing pane beside it
|
|
45
|
+
// crushed twenty-four columns of tree down to four. A sidebar is a
|
|
46
|
+
// decision about the window, not a column of the document.
|
|
47
|
+
shrink: 0,
|
|
42
48
|
border: { style: theme.border, sides: { right: true } },
|
|
43
49
|
direction: 'column',
|
|
44
50
|
padding: { left: 1 },
|