@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.
@@ -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,sCAuLjB,CAAC"}
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"}
@@ -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',
@@ -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;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, FeedScrollbar } from './shared.js';
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
- const drawn = entries.map((entry, i) => h(FeedEntry, {
173
- key: i,
174
- onHeight: (height) => { heights.current[i] = height; },
175
- }, entry));
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 ? h(FeedScrollbar, {}) : null);
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
  });
@@ -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
- onHeight(height: number): void;
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;;;;;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;oBACG,MAAM,UAAU,MAAM,GAAG,IAAI;yCAW9C,CAAC"}
@@ -1,17 +1,21 @@
1
- import { defineComponent, h, useEffect, useMeasure, useTheme } from '@textui/core';
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(() => { onHeight(measured.height); }, [measured.height]);
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,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.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.2.0"
49
+ "@textui/core": "^0.4.0"
50
50
  },
51
51
  "publishConfig": {
52
52
  "access": "public"
@@ -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, FeedScrollbar } from './shared.js';
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
- const drawn = entries.map((entry, i) => h(FeedEntry, {
213
- key: i,
214
- onHeight: (height: number) => { heights.current[i] = height; },
215
- }, entry));
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 ? h(FeedScrollbar, {}) : null,
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
  });
@@ -1,23 +1,29 @@
1
1
  import type { BoxProps } from '@textui/core';
2
- import { defineComponent, h, useEffect, useMeasure, useTheme } from '@textui/core';
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 & { onHeight(height: number): void }>(
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(() => { onHeight(measured.height); }, [measured.height]);
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 },