@textui/widgets 0.2.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.
@@ -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;AAiB7C,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,2DA8Kf,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;AAiB7C,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,2DAsOf,CAAC"}
package/dist/data/feed.js CHANGED
@@ -28,6 +28,20 @@ export const Feed = defineComponent('Feed', (props) => {
28
28
  const measured = useMeasure();
29
29
  const extent = useScrollExtent();
30
30
  const heights = useRef([]);
31
+ /**
32
+ * The width those heights were measured at.
33
+ *
34
+ * A height is a statement about a width - what a paragraph wrapped to in a
35
+ * pane this wide - so a resize does not make them stale, it makes them
36
+ * about a layout that no longer exists. Kept and compared rather than
37
+ * ignored, because the alternative is a feed that scrolls to the wrong
38
+ * place for ever after the window is dragged.
39
+ */
40
+ const heightsAt = useRef(-1);
41
+ /** One handler for every entry, so an entry's props do not change with it. */
42
+ const report = useRef((index, height) => {
43
+ heights.current[index] = height;
44
+ });
31
45
  // The catalog's rule, and this has to follow it like every other viewport:
32
46
  // given `flex`, a `height`, a `maxHeight` or a `basis`, the layout decided
33
47
  // the size and the feed fills it and scrolls; given none of those, the
@@ -169,10 +183,54 @@ export const Feed = defineComponent('Feed', (props) => {
169
183
  scrollTo(event.name === 'pageup' ? top - page : top + page);
170
184
  return true;
171
185
  }, { global: true, enabled: pageKeys === 'always' });
186
+ if (measured.width !== heightsAt.current) {
187
+ heightsAt.current = measured.width;
188
+ heights.current = [];
189
+ }
190
+ /*
191
+ * Which entries are far enough away not to be worth drawing.
192
+ *
193
+ * A feed lays out every entry it holds, and laying one out means wrapping
194
+ * its text - so a viewport thirty rows tall over a transcript of six
195
+ * hundred entries spent every frame measuring the five hundred and seventy
196
+ * nobody can see. That cost is paid per *frame*, not per change: a
197
+ * keystroke in a field elsewhere on the screen, a caret blinking, a
198
+ * spinner. It is why a long conversation is heavy to type into and a short
199
+ * one is not.
200
+ *
201
+ * An entry is skipped only once it has been measured, which is what keeps
202
+ * this honest: the first frame draws everything and learns every height,
203
+ * and from then on the ones outside the window are replaced by a box of
204
+ * exactly the height they had. Nothing is estimated, so the extent, the
205
+ * scrollbar and the position of every entry are what they would have been
206
+ * had all of them been drawn.
207
+ *
208
+ * A screenful either side, so scrolling and paging land on something that
209
+ * is already there rather than on a frame of blanks.
210
+ */
211
+ const view = Math.max(1, measured.height);
212
+ const skipped = [];
213
+ {
214
+ let y = 0;
215
+ for (let i = 0; i < count; i++) {
216
+ const height = heights.current[i];
217
+ // Zero is not a height that has been learned, whatever wrote it: an
218
+ // entry standing in for itself at nothing tall is one that can never
219
+ // be drawn again to find out how tall it is.
220
+ if (height === undefined || height <= 0) {
221
+ skipped[i] = false;
222
+ continue;
223
+ }
224
+ skipped[i] = fills && (y + height < top - view || y > top + view + view);
225
+ y += height;
226
+ }
227
+ }
172
228
  const drawn = entries.map((entry, i) => h(FeedEntry, {
173
229
  key: i,
174
- onHeight: (height) => { heights.current[i] = height; },
175
- }, entry));
230
+ index: i,
231
+ onHeight: report.current,
232
+ ...(skipped[i] ? { placeholder: true, height: heights.current[i] } : {}),
233
+ }, skipped[i] ? null : entry));
176
234
  // Content-sized: draw everything and let the box grow. Clamping to a
177
235
  // measurement here would clamp to how tall this happened to be last frame,
178
236
  // which for a box sized *by* its own content is nothing at all - the entries
@@ -4,9 +4,26 @@ 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
- onHeight(height: number): void;
15
+ index: number;
16
+ /**
17
+ * Standing in for an entry that is not drawn, at the height it had.
18
+ *
19
+ * A stand-in must not answer the question it is an answer to. Its height is
20
+ * one the feed already knows - it is where it came from - and reporting it
21
+ * back is how a measurement gets to depend on itself: a round where a
22
+ * stand-in came out any shorter would teach the feed that it *was* shorter,
23
+ * and the next round would place the rest of them on top of each other.
24
+ */
25
+ placeholder?: boolean;
26
+ onHeight(index: number, height: number): void;
10
27
  }) => import("@textui/core").RenderOutput;
11
28
  export declare const FeedScrollbar: (props: Record<string, never>) => import("@textui/core").RenderOutput;
12
29
  //# 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;;;;;GAKG;AACH,eAAO,MAAM,SAAS;qBAAiD,MAAM,GAAG,IAAI;yCAQnF,CAAC;AAEF,eAAO,MAAM,aAAa,uEAGxB,CAAC"}
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;IACb;;;;;;;;OAQG;kBACW,OAAO;oBACL,MAAM,UAAU,MAAM,GAAG,IAAI;yCAY9C,CAAC;AAEF,eAAO,MAAM,aAAa,uEAGxB,CAAC"}
@@ -4,11 +4,21 @@ import { defineComponent, h, useEffect, useMeasure, useTheme } from '@textui/cor
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, placeholder, onHeight, children, ...rest } = props;
10
16
  const measured = useMeasure();
11
- useEffect(() => { onHeight(measured.height); }, [measured.height]);
17
+ useEffect(() => {
18
+ if (placeholder)
19
+ return;
20
+ onHeight(index, measured.height);
21
+ }, [measured.height, placeholder]);
12
22
  return h('box', { direction: 'column', ...rest }, children);
13
23
  });
14
24
  export const FeedScrollbar = defineComponent('FeedScrollbar', () => {
@@ -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,iFA6DzB,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.2.0",
3
+ "version": "0.3.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.2.0"
49
+ "@textui/core": "^0.3.0"
50
50
  },
51
51
  "publishConfig": {
52
52
  "access": "public"
package/src/data/feed.ts CHANGED
@@ -85,6 +85,20 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
85
85
  const measured = useMeasure();
86
86
  const extent = useScrollExtent();
87
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
+ });
88
102
 
89
103
  // The catalog's rule, and this has to follow it like every other viewport:
90
104
  // given `flex`, a `height`, a `maxHeight` or a `basis`, the layout decided
@@ -209,10 +223,52 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
209
223
  { global: true, enabled: pageKeys === 'always' },
210
224
  );
211
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
+ }
212
266
  const drawn = entries.map((entry, i) => h(FeedEntry, {
213
267
  key: i,
214
- onHeight: (height: number) => { heights.current[i] = height; },
215
- }, entry));
268
+ index: i,
269
+ onHeight: report.current,
270
+ ...(skipped[i] ? { placeholder: true, height: heights.current[i] as number } : {}),
271
+ }, skipped[i] ? null : entry));
216
272
 
217
273
  // Content-sized: draw everything and let the box grow. Clamping to a
218
274
  // measurement here would clamp to how tall this happened to be last frame,
@@ -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 & { onHeight(height: number): void }>(
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(() => { onHeight(measured.height); }, [measured.height]);
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
  );
@@ -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 },