@gtkx/components 1.6.0 → 2.0.0-beta.10

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.
Files changed (78) hide show
  1. package/README.md +47 -44
  2. package/dist/column-view.d.ts.map +1 -1
  3. package/dist/column-view.js +1 -0
  4. package/dist/column-view.js.map +1 -1
  5. package/dist/combo-row.d.ts.map +1 -0
  6. package/dist/{adw/combo-row.js → combo-row.js} +1 -1
  7. package/dist/combo-row.js.map +1 -0
  8. package/dist/index.d.ts +3 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +2 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/internal/cells.d.ts +11 -1
  13. package/dist/internal/cells.d.ts.map +1 -1
  14. package/dist/internal/cells.js +124 -26
  15. package/dist/internal/cells.js.map +1 -1
  16. package/dist/internal/collection-index.d.ts +1 -2
  17. package/dist/internal/collection-index.d.ts.map +1 -1
  18. package/dist/internal/collection-index.js +1 -2
  19. package/dist/internal/collection-index.js.map +1 -1
  20. package/dist/internal/collection-model.d.ts +5 -5
  21. package/dist/internal/collection-model.d.ts.map +1 -1
  22. package/dist/internal/collection-model.js +38 -43
  23. package/dist/internal/collection-model.js.map +1 -1
  24. package/dist/internal/expansion.d.ts.map +1 -1
  25. package/dist/internal/expansion.js +2 -8
  26. package/dist/internal/expansion.js.map +1 -1
  27. package/dist/internal/slots.d.ts.map +1 -1
  28. package/dist/internal/slots.js +1 -6
  29. package/dist/internal/slots.js.map +1 -1
  30. package/dist/internal/tree-expansion.js +2 -2
  31. package/dist/internal/tree-expansion.js.map +1 -1
  32. package/dist/internal/tree-order.d.ts +1 -2
  33. package/dist/internal/tree-order.d.ts.map +1 -1
  34. package/dist/internal/tree-order.js +1 -4
  35. package/dist/internal/tree-order.js.map +1 -1
  36. package/dist/internal/use-widget-ref.d.ts +2 -2
  37. package/dist/internal/use-widget-ref.d.ts.map +1 -1
  38. package/dist/internal/use-widget-ref.js.map +1 -1
  39. package/dist/list-view.d.ts.map +1 -1
  40. package/dist/list-view.js +1 -0
  41. package/dist/list-view.js.map +1 -1
  42. package/dist/toast.d.ts.map +1 -0
  43. package/dist/toast.js.map +1 -0
  44. package/dist/types.d.ts +45 -4
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/types.js.map +1 -1
  47. package/package.json +13 -14
  48. package/src/column-view.tsx +1 -0
  49. package/src/{adw/combo-row.tsx → combo-row.tsx} +2 -2
  50. package/src/index.ts +7 -0
  51. package/src/internal/cells.tsx +200 -40
  52. package/src/internal/collection-index.ts +1 -2
  53. package/src/internal/collection-model.ts +44 -50
  54. package/src/internal/expansion.ts +2 -10
  55. package/src/internal/slots.ts +1 -9
  56. package/src/internal/tree-expansion.ts +2 -2
  57. package/src/internal/tree-order.ts +0 -5
  58. package/src/internal/use-widget-ref.ts +2 -2
  59. package/src/list-view.tsx +1 -0
  60. package/src/types.ts +55 -3
  61. package/dist/adw/combo-row.d.ts.map +0 -1
  62. package/dist/adw/combo-row.js.map +0 -1
  63. package/dist/adw/index.d.ts +0 -4
  64. package/dist/adw/index.d.ts.map +0 -1
  65. package/dist/adw/index.js +0 -3
  66. package/dist/adw/index.js.map +0 -1
  67. package/dist/adw/toast.d.ts.map +0 -1
  68. package/dist/adw/toast.js.map +0 -1
  69. package/dist/adw/types.d.ts +0 -36
  70. package/dist/adw/types.d.ts.map +0 -1
  71. package/dist/adw/types.js +0 -2
  72. package/dist/adw/types.js.map +0 -1
  73. package/src/adw/index.ts +0 -9
  74. package/src/adw/types.ts +0 -46
  75. /package/dist/{adw/combo-row.d.ts → combo-row.d.ts} +0 -0
  76. /package/dist/{adw/toast.d.ts → toast.d.ts} +0 -0
  77. /package/dist/{adw/toast.js → toast.js} +0 -0
  78. /package/src/{adw/toast.tsx → toast.tsx} +0 -0
@@ -3,8 +3,8 @@ import type { ReactElement, ReactNode } from "react";
3
3
  import * as Gtk from "@gtkx/gi/gtk";
4
4
  import { GtkSignalListItemFactory, GtkTreeExpander } from "@gtkx/jsx/gtk";
5
5
  import { createPortal, useProperty } from "@gtkx/react";
6
- import { setObjectProperty, t } from "@gtkx/runtime";
7
- import { memo, useLayoutEffect, useState, useSyncExternalStore } from "react";
6
+ import { setProperty, t } from "@gtkx/runtime";
7
+ import { memo, useInsertionEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
8
8
  import type {
9
9
  ExpanderDescriptions,
10
10
  ListItem,
@@ -34,6 +34,7 @@ type CellHost = FactoryHost & {
34
34
  type CellEntry<H extends FactoryHost> = {
35
35
  key: string;
36
36
  host: H;
37
+ identity: ItemIdentityStore;
37
38
  };
38
39
 
39
40
  type FactoryHandlers = {
@@ -88,14 +89,34 @@ type ItemSlot = {
88
89
  args: ListItemRenderArgs<unknown>;
89
90
  };
90
91
 
92
+ type CollectionState = {
93
+ collection: Collection;
94
+ expandedIds: string[] | null | undefined;
95
+ };
96
+
97
+ type CollectionStateStore = {
98
+ read: () => CollectionState;
99
+ write: (state: CollectionState) => void;
100
+ };
101
+
102
+ type ItemIdentity = {
103
+ item: ListItem | undefined;
104
+ position: number;
105
+ isExpanded: boolean;
106
+ };
107
+
108
+ type ItemIdentityStore = {
109
+ read: () => ItemIdentity | null;
110
+ write: (identity: ItemIdentity) => void;
111
+ };
112
+
91
113
  type ItemCellProps = {
92
114
  entry: CellEntry<Gtk.ListItem>;
93
115
  render: ListItemRenderer<never>;
94
- collection: Collection;
116
+ state: CollectionStateStore;
95
117
  hasExpander: boolean;
96
- expandedIds: string[] | null | undefined;
97
118
  expanderDescriptions: ExpanderDescriptions | null | undefined;
98
- };
119
+ } & ItemIdentity;
99
120
 
100
121
  type ItemPortalsProps = {
101
122
  registry: CellRegistry<Gtk.ListItem>;
@@ -109,9 +130,8 @@ type ItemPortalsProps = {
109
130
  type ItemRowProps = {
110
131
  entry: CellEntry<Gtk.ColumnViewRow>;
111
132
  rowProps: ListRowPropsResolver<never>;
112
- collection: Collection;
113
- expandedIds: string[] | null | undefined;
114
- };
133
+ state: CollectionStateStore;
134
+ } & ItemIdentity;
115
135
 
116
136
  type RowPortalsProps = {
117
137
  registry: CellRegistry<Gtk.ColumnViewRow>;
@@ -142,8 +162,8 @@ type RowSlot = {
142
162
  portals: ReactNode;
143
163
  };
144
164
 
145
- const ItemCell = memo(ItemCellImpl);
146
- const ItemRow = memo(ItemRowImpl);
165
+ const ItemCell = memo(ItemCellImpl, isSameItemCell);
166
+ const ItemRow = memo(ItemRowImpl, isSameItemRow);
147
167
  const HeaderCell = memo(HeaderCellImpl);
148
168
  const CELL_OPTIONS: CellRegistryOptions<Gtk.ListItem> = { isHost: isListItem, prepare: prepareCell };
149
169
  const HEADER_OPTIONS: CellRegistryOptions<Gtk.ListHeader> = { isHost: isListHeader, prepare: prepareCell };
@@ -184,7 +204,7 @@ function notifyRegistry<H extends FactoryHost>(state: CellRegistryState<H>): voi
184
204
  function createEntry<H extends FactoryHost>(state: CellRegistryState<H>, host: H): CellEntry<H> {
185
205
  state.serial += 1;
186
206
 
187
- return { key: `gtkx-cell-${String(state.serial)}`, host };
207
+ return { key: `gtkx-cell-${String(state.serial)}`, host, identity: createItemIdentityStore() };
188
208
  }
189
209
 
190
210
  function setupHost<H extends FactoryHost>(state: CellRegistryState<H>, host: H): void {
@@ -283,9 +303,13 @@ function isRowWanted(options: ItemSlotOptions, item: ListItem): boolean {
283
303
  return options.expandedIds?.includes(item.id) ?? false;
284
304
  }
285
305
 
286
- function itemArgs(item: ListItem, options: ItemSlotOptions): ListItemRenderArgs<unknown> {
287
- const args: ListItemRenderArgs<unknown> = { item: item.value, index: options.position ?? 0 };
288
- const row = options.row;
306
+ function itemArgs(
307
+ item: ListItem,
308
+ position: number | undefined,
309
+ row: Gtk.TreeListRow | null,
310
+ isExpanded: boolean,
311
+ ): ListItemRenderArgs<unknown> {
312
+ const args: ListItemRenderArgs<unknown> = { item: item.value, index: position ?? 0 };
289
313
 
290
314
  if (row === null) {
291
315
  return args;
@@ -294,7 +318,7 @@ function itemArgs(item: ListItem, options: ItemSlotOptions): ListItemRenderArgs<
294
318
  args.depth = row.getDepth();
295
319
 
296
320
  if (row.isExpandable()) {
297
- args.isExpanded = isRowWanted(options, item);
321
+ args.isExpanded = isExpanded;
298
322
  }
299
323
 
300
324
  return args;
@@ -308,21 +332,127 @@ function slotFor(options: ItemSlotOptions): ItemSlot | null {
308
332
  return null;
309
333
  }
310
334
 
311
- return { item, row: options.row, args: itemArgs(item, options) };
335
+ return {
336
+ item,
337
+ row: options.row,
338
+ args: itemArgs(item, options.position, options.row, isRowWanted(options, item)),
339
+ };
312
340
  }
313
341
 
314
342
  function useItemSlot(
315
343
  entry: CellEntry<PositionedHost>,
316
- collection: Collection,
317
- expandedIds: string[] | null | undefined,
344
+ state: CollectionStateStore,
345
+ identity: ItemIdentity,
318
346
  ): ItemSlot | null {
319
347
  const position = useProperty(entry.host, "position");
320
348
  const item = useProperty(entry.host, "item") ?? null;
321
349
  const row = item instanceof Gtk.TreeListRow ? item : null;
322
350
 
351
+ if (position === identity.position) {
352
+ return identity.item === undefined
353
+ ? null
354
+ : {
355
+ item: identity.item,
356
+ row,
357
+ args: itemArgs(identity.item, position, row, identity.isExpanded),
358
+ };
359
+ }
360
+
361
+ const { collection, expandedIds } = state.read();
362
+
323
363
  return slotFor({ item, position, row, collection, expandedIds });
324
364
  }
325
365
 
366
+ function itemIdentity(
367
+ entry: CellEntry<PositionedHost>,
368
+ collection: Collection,
369
+ expandedIds: string[] | null | undefined,
370
+ ): ItemIdentity {
371
+ const ref = slotRefFor(entry.host.getItem());
372
+ const item = ref === null ? undefined : collection.itemAt(ref);
373
+
374
+ return {
375
+ item,
376
+ position: entry.host.getPosition(),
377
+ isExpanded: item === undefined ? false : (expandedIds?.includes(item.id) ?? false),
378
+ };
379
+ }
380
+
381
+ function isSameItemIdentity(previous: ItemIdentity, next: ItemIdentity): boolean {
382
+ return (
383
+ previous.item === next.item &&
384
+ previous.position === next.position &&
385
+ previous.isExpanded === next.isExpanded
386
+ );
387
+ }
388
+
389
+ function isSameItemCell(previous: ItemCellProps, next: ItemCellProps): boolean {
390
+ const identity = previous.entry.identity.read() ?? previous;
391
+
392
+ return (
393
+ previous.entry === next.entry &&
394
+ previous.render === next.render &&
395
+ previous.hasExpander === next.hasExpander &&
396
+ previous.expanderDescriptions === next.expanderDescriptions &&
397
+ isSameItemIdentity(identity, next)
398
+ );
399
+ }
400
+
401
+ function isSameItemRow(previous: ItemRowProps, next: ItemRowProps): boolean {
402
+ const identity = previous.entry.identity.read() ?? previous;
403
+
404
+ return previous.entry === next.entry && previous.rowProps === next.rowProps && isSameItemIdentity(identity, next);
405
+ }
406
+
407
+ function createItemIdentityStore(): ItemIdentityStore {
408
+ let current: ItemIdentity | null = null;
409
+
410
+ return {
411
+ read: () => current,
412
+ write: (identity) => {
413
+ current = identity;
414
+ },
415
+ };
416
+ }
417
+
418
+ function renderedIdentity(entry: CellEntry<PositionedHost>, slot: ItemSlot | null): ItemIdentity {
419
+ return {
420
+ item: slot?.item,
421
+ position: slot?.args.index ?? entry.host.getPosition(),
422
+ isExpanded: slot?.args.isExpanded ?? false,
423
+ };
424
+ }
425
+
426
+ function useRememberIdentity(entry: CellEntry<PositionedHost>, slot: ItemSlot | null): void {
427
+ useLayoutEffect(() => {
428
+ entry.identity.write(renderedIdentity(entry, slot));
429
+ });
430
+ }
431
+
432
+ function createCollectionStateStore(initial: CollectionState): CollectionStateStore {
433
+ let current = initial;
434
+
435
+ return {
436
+ read: () => current,
437
+ write: (state) => {
438
+ current = state;
439
+ },
440
+ };
441
+ }
442
+
443
+ function useCollectionState(
444
+ collection: Collection,
445
+ expandedIds: string[] | null | undefined,
446
+ ): CollectionStateStore {
447
+ const [state] = useState(() => createCollectionStateStore({ collection, expandedIds }));
448
+
449
+ useInsertionEffect(() => {
450
+ state.write({ collection, expandedIds });
451
+ }, [state, collection, expandedIds]);
452
+
453
+ return state;
454
+ }
455
+
326
456
  function expanderDescriptionFor(
327
457
  slot: ItemSlot,
328
458
  descriptions: ExpanderDescriptions | null | undefined,
@@ -402,38 +532,60 @@ function rowPropsFor(slot: ItemSlot | null, rowProps: ListRowPropsResolver<never
402
532
  }
403
533
 
404
534
  function setRowText(host: Gtk.ColumnViewRow, name: string, value: string | null): void {
405
- setObjectProperty(host, name, ROW_TEXT_DESCRIPTOR, value);
535
+ setProperty(host, name, ROW_TEXT_DESCRIPTOR, value);
406
536
  }
407
537
 
408
- function applyRowProps(host: Gtk.ColumnViewRow, props: ResolvedRowProps): void {
409
- setRowText(host, "accessible-label", props.accessibleLabel);
410
- setRowText(host, "accessible-description", props.accessibleDescription);
411
- host.setActivatable(props.isActivatable);
412
- host.setFocusable(props.isFocusable);
413
- host.setSelectable(props.isSelectable);
538
+ function applyRowProps(
539
+ host: Gtk.ColumnViewRow,
540
+ props: ResolvedRowProps,
541
+ previous: ResolvedRowProps | null,
542
+ ): void {
543
+ if (previous?.accessibleLabel !== props.accessibleLabel) {
544
+ setRowText(host, "accessible-label", props.accessibleLabel);
545
+ }
546
+
547
+ if (previous?.accessibleDescription !== props.accessibleDescription) {
548
+ setRowText(host, "accessible-description", props.accessibleDescription);
549
+ }
550
+
551
+ if (previous?.isActivatable !== props.isActivatable) {
552
+ host.setActivatable(props.isActivatable);
553
+ }
554
+
555
+ if (previous?.isFocusable !== props.isFocusable) {
556
+ host.setFocusable(props.isFocusable);
557
+ }
558
+
559
+ if (previous?.isSelectable !== props.isSelectable) {
560
+ host.setSelectable(props.isSelectable);
561
+ }
414
562
  }
415
563
 
416
564
  function ItemCellImpl({
417
565
  entry,
418
566
  render,
419
- collection,
567
+ state,
420
568
  hasExpander,
421
- expandedIds,
422
569
  expanderDescriptions,
570
+ ...identity
423
571
  }: ItemCellProps): ReactNode {
424
- const slot = useItemSlot(entry, collection, expandedIds);
572
+ const slot = useItemSlot(entry, state, identity);
573
+ useRememberIdentity(entry, slot);
425
574
  const body = itemBody(slot, render, hasExpander, expanderDescriptions);
426
575
 
427
576
  return createPortal(body, entry.host, entry.key);
428
577
  }
429
578
 
430
- function ItemRowImpl({ entry, rowProps, collection, expandedIds }: ItemRowProps): ReactNode {
431
- const slot = useItemSlot(entry, collection, expandedIds);
579
+ function ItemRowImpl({ entry, rowProps, state, ...identity }: ItemRowProps): ReactNode {
580
+ const slot = useItemSlot(entry, state, identity);
581
+ useRememberIdentity(entry, slot);
432
582
  const props = rowPropsFor(slot, rowProps);
583
+ const previous = useRef<ResolvedRowProps | null>(null);
433
584
 
434
585
  useLayoutEffect(() => {
435
- applyRowProps(entry.host, props);
436
- });
586
+ applyRowProps(entry.host, props, previous.current);
587
+ previous.current = props;
588
+ }, [entry.host, props]);
437
589
 
438
590
  return null;
439
591
  }
@@ -458,29 +610,37 @@ const ItemPortals = ({
458
610
  hasExpander,
459
611
  expandedIds,
460
612
  expanderDescriptions,
461
- }: ItemPortalsProps): ReactNode =>
462
- usePortalEntries(registry).map((entry) => (
613
+ }: ItemPortalsProps): ReactNode => {
614
+ const entries = usePortalEntries(registry);
615
+ const state = useCollectionState(collection, expandedIds);
616
+
617
+ return entries.map((entry) => (
463
618
  <ItemCell
464
619
  key={entry.key}
465
620
  entry={entry}
466
621
  render={render}
467
- collection={collection}
622
+ state={state}
468
623
  hasExpander={hasExpander ?? true}
469
- expandedIds={expandedIds}
470
624
  expanderDescriptions={expanderDescriptions}
625
+ {...itemIdentity(entry, collection, expandedIds)}
471
626
  />
472
627
  ));
628
+ };
473
629
 
474
- const RowPortals = ({ registry, rowProps, collection, expandedIds }: RowPortalsProps): ReactNode =>
475
- usePortalEntries(registry).map((entry) => (
630
+ const RowPortals = ({ registry, rowProps, collection, expandedIds }: RowPortalsProps): ReactNode => {
631
+ const entries = usePortalEntries(registry);
632
+ const state = useCollectionState(collection, expandedIds);
633
+
634
+ return entries.map((entry) => (
476
635
  <ItemRow
477
636
  key={entry.key}
478
637
  entry={entry}
479
638
  rowProps={rowProps}
480
- collection={collection}
481
- expandedIds={expandedIds}
639
+ state={state}
640
+ {...itemIdentity(entry, collection, expandedIds)}
482
641
  />
483
642
  ));
643
+ };
484
644
 
485
645
  const HeaderPortals = ({ registry, render, collection }: HeaderPortalsProps): ReactNode =>
486
646
  usePortalEntries(registry).map((entry) => (
@@ -25,7 +25,6 @@ type IndexState = {
25
25
 
26
26
  type ParentItem = ListItem & { children: ListItem[] };
27
27
 
28
- const emptyLevel = (): Level => ({ path: "", items: [], expandableFlags: [] });
29
28
  const hasChildren = (item: ListItem): item is ParentItem => item.children !== undefined && item.children.length > 0;
30
29
 
31
30
  function newLevel(state: IndexState, path: string, items: ListItem[]): Level {
@@ -141,4 +140,4 @@ function createCollectionIndex(
141
140
  };
142
141
  }
143
142
 
144
- export { createCollectionIndex, emptyLevel, type CollectionIndex, type Level };
143
+ export { createCollectionIndex, type CollectionIndex, type Level };
@@ -4,14 +4,14 @@ import * as Gtk from "@gtkx/gi/gtk";
4
4
  import { registerClass } from "@gtkx/runtime";
5
5
  import type { CollectionIndex, Level } from "./collection-index.js";
6
6
  import type { TreeExpansion } from "./tree-expansion.js";
7
- import { createCollectionIndex, emptyLevel } from "./collection-index.js";
7
+ import { createCollectionIndex } from "./collection-index.js";
8
8
  import { encodePart } from "./keys.js";
9
9
  import { adoptIndex, createTreeExpansion, pruneSlots } from "./tree-expansion.js";
10
10
 
11
11
  type LevelStore = Gio.ListModel & LazyLevelStore;
12
12
 
13
13
  type SlotRef = {
14
- store: LevelStore;
14
+ store: LazyLevelStore;
15
15
  slot: number;
16
16
  };
17
17
 
@@ -93,22 +93,13 @@ function slotRefFor(value: GObject.Object | null): SlotRef | null {
93
93
  return ref === undefined || ref.slot === -1 ? null : ref;
94
94
  }
95
95
 
96
- function slotPathAt(store: LevelStore, slot: number): string {
96
+ function slotPathAt(store: LazyLevelStore, slot: number): string {
97
97
  return store.level.path + encodePart(String(slot));
98
98
  }
99
99
 
100
- function growStore(store: LevelStore): void {
101
- for (let slot = store.refs.length; slot < store.level.items.length; slot++) {
102
- store.refs.push({ store, slot });
103
- store.objects.push(null);
104
- store.childStores.push(null);
105
- }
106
- }
107
-
108
100
  function newLevelStore(level: Level): LevelStore {
109
101
  const store = new (registeredStoreClass())() as LevelStore;
110
102
  store.level = level;
111
- growStore(store);
112
103
 
113
104
  return store;
114
105
  }
@@ -116,6 +107,10 @@ function newLevelStore(level: Level): LevelStore {
116
107
  function collectFlips(previous: Level, next: Level, overlap: number): Set<number> {
117
108
  const flipped: Set<number> = new Set();
118
109
 
110
+ if (previous.expandableFlags.length === 0 && next.expandableFlags.length === 0) {
111
+ return flipped;
112
+ }
113
+
119
114
  for (let slot = 0; slot < overlap; slot++) {
120
115
  if ((previous.expandableFlags[slot] ?? false) !== (next.expandableFlags[slot] ?? false)) {
121
116
  flipped.add(slot);
@@ -133,30 +128,30 @@ function pruneFlips(context: SyncContext, store: LevelStore, flipped: Set<number
133
128
  pruneSlots(context.expansion, store.level.path, (slot) => flipped.has(slot));
134
129
 
135
130
  for (const slot of flipped) {
136
- store.childStores[slot] = null;
131
+ store.childStores.delete(slot);
137
132
  }
138
133
  }
139
134
 
140
135
  function detachRefs(store: LevelStore, nextLength: number): void {
141
- for (let slot = nextLength; slot < store.refs.length; slot++) {
142
- const ref = store.refs[slot];
143
-
144
- if (ref !== undefined) {
145
- ref.slot = -1;
136
+ for (const [slot, ref] of store.refs) {
137
+ if (slot < nextLength) {
138
+ continue;
146
139
  }
140
+
141
+ ref.slot = -1;
142
+ store.refs.delete(slot);
143
+ store.objects.delete(slot);
144
+ store.childStores.delete(slot);
147
145
  }
148
146
  }
149
147
 
150
- function shrinkStore(context: SyncContext, store: LevelStore, nextLength: number): void {
151
- if (store.refs.length <= nextLength) {
148
+ function shrinkStore(context: SyncContext, store: LevelStore, previousLength: number, nextLength: number): void {
149
+ if (previousLength <= nextLength) {
152
150
  return;
153
151
  }
154
152
 
155
153
  detachRefs(store, nextLength);
156
154
  pruneSlots(context.expansion, store.level.path, (slot) => slot >= nextLength);
157
- store.refs.length = nextLength;
158
- store.objects.length = nextLength;
159
- store.childStores.length = nextLength;
160
155
  }
161
156
 
162
157
  function emitTailSplice(store: LevelStore, previousLength: number, nextLength: number): void {
@@ -205,7 +200,7 @@ function childSync(
205
200
  slot: number,
206
201
  flipped: Set<number>,
207
202
  ): LevelSync | undefined {
208
- const child = store.childStores[slot] ?? null;
203
+ const child = store.childStores.get(slot) ?? null;
209
204
 
210
205
  if (child === null || flipped.has(slot)) {
211
206
  return undefined;
@@ -218,8 +213,13 @@ function childSync(
218
213
 
219
214
  function childSyncs(context: SyncContext, store: LevelStore, overlap: number, flipped: Set<number>): LevelSync[] {
220
215
  const syncs: LevelSync[] = [];
216
+ const slots = store.childStores.keys().toArray().toSorted((left, right) => left - right);
217
+
218
+ for (const slot of slots) {
219
+ if (slot >= overlap) {
220
+ continue;
221
+ }
221
222
 
222
- for (let slot = 0; slot < overlap; slot++) {
223
223
  const sync = childSync(context, store, slot, flipped);
224
224
 
225
225
  if (sync !== undefined) {
@@ -232,25 +232,19 @@ function childSyncs(context: SyncContext, store: LevelStore, overlap: number, fl
232
232
 
233
233
  function syncEntry(context: SyncContext, entry: LevelSync): LevelSync[] {
234
234
  const { store, level } = entry;
235
- const previousLength = store.refs.length;
235
+ const previous = store.level;
236
+ const previousLength = previous.items.length;
236
237
  const overlap = Math.min(previousLength, level.items.length);
237
- const flipped = collectFlips(store.level, level, overlap);
238
+ const flipped = collectFlips(previous, level, overlap);
238
239
  store.level = level;
239
240
  pruneFlips(context, store, flipped);
240
- shrinkStore(context, store, level.items.length);
241
- growStore(store);
241
+ shrinkStore(context, store, previousLength, level.items.length);
242
242
  emitTailSplice(store, previousLength, level.items.length);
243
243
  emitFlips(store, flipped);
244
244
 
245
245
  return childSyncs(context, store, overlap, flipped);
246
246
  }
247
247
 
248
- function pushSyncs(pending: LevelSync[], syncs: LevelSync[]): void {
249
- for (const sync of syncs) {
250
- pending.push(sync);
251
- }
252
- }
253
-
254
248
  function syncLevel(context: SyncContext, store: LevelStore, level: Level): void {
255
249
  const pending: LevelSync[] = [{ store, level }];
256
250
 
@@ -258,13 +252,13 @@ function syncLevel(context: SyncContext, store: LevelStore, level: Level): void
258
252
  const entry = pending.pop();
259
253
 
260
254
  if (entry !== undefined) {
261
- pushSyncs(pending, syncEntry(context, entry).toReversed());
255
+ pending.push(...syncEntry(context, entry).toReversed());
262
256
  }
263
257
  }
264
258
  }
265
259
 
266
- function ensureChildStore(index: CollectionIndex, store: LevelStore, slot: number): LevelStore | null {
267
- const existing = store.childStores[slot] ?? null;
260
+ function ensureChildStore(index: CollectionIndex, store: LazyLevelStore, slot: number): LevelStore | null {
261
+ const existing = store.childStores.get(slot) ?? null;
268
262
 
269
263
  if (existing !== null || !(store.level.expandableFlags[slot] ?? false)) {
270
264
  return existing;
@@ -277,7 +271,7 @@ function ensureChildStore(index: CollectionIndex, store: LevelStore, slot: numbe
277
271
  }
278
272
 
279
273
  const created = newLevelStore(level);
280
- store.childStores[slot] = created;
274
+ store.childStores.set(slot, created);
281
275
 
282
276
  return created;
283
277
  }
@@ -423,14 +417,16 @@ function createCollectionModel(): CollectionModel {
423
417
 
424
418
  class LazyLevelStore extends GObject.Object implements Gio.ListModelImpl {
425
419
  declare itemsChanged: Gio.ListModel["itemsChanged"];
426
- level: Level = emptyLevel();
427
- refs: SlotRef[] = [];
428
- objects: (Gtk.StringObject | null)[] = [];
429
- childStores: (LevelStore | null)[] = [];
420
+ level: Level = { path: "", items: [], expandableFlags: [] };
421
+ refs: Map<number, SlotRef> = new Map();
422
+ objects: Map<number, Gtk.StringObject> = new Map();
423
+ childStores: Map<number, LevelStore> = new Map();
430
424
 
431
- private createItem(position: number, ref: SlotRef): Gtk.StringObject {
425
+ private createItem(position: number): Gtk.StringObject {
426
+ const ref = { store: this, slot: position };
432
427
  const created = Gtk.StringObject.new("");
433
- this.objects[position] = created;
428
+ this.refs.set(position, ref);
429
+ this.objects.set(position, created);
434
430
  SLOTS.set(created, ref);
435
431
 
436
432
  return created;
@@ -441,17 +437,15 @@ class LazyLevelStore extends GObject.Object implements Gio.ListModelImpl {
441
437
  }
442
438
 
443
439
  vfuncGetNItems(): number {
444
- return this.refs.length;
440
+ return this.level.items.length;
445
441
  }
446
442
 
447
443
  vfuncGetItem(position: number): GObject.Object | null {
448
- const ref = this.refs[position];
449
-
450
- if (ref === undefined) {
444
+ if (position < 0 || position >= this.level.items.length) {
451
445
  return null;
452
446
  }
453
447
 
454
- return this.objects[position] ?? this.createItem(position, ref);
448
+ return this.objects.get(position) ?? this.createItem(position);
455
449
  }
456
450
  }
457
451
 
@@ -30,10 +30,6 @@ type ExpansionContext = {
30
30
  onExpandedChange?: ((ids: string[]) => void) | null | undefined;
31
31
  };
32
32
 
33
- function newLastExpansion(): LastExpansion {
34
- return { key: "" };
35
- }
36
-
37
33
  function hasSameExpansion(expanded: Set<string>, wanted: Set<string>): boolean {
38
34
  return expanded.size === wanted.size && wanted.isSubsetOf(expanded);
39
35
  }
@@ -95,10 +91,6 @@ function applyControlledExpansion(context: ExpansionContext, expandedIds: string
95
91
  reportExpansion(context);
96
92
  }
97
93
 
98
- function isExpansionIdle(collection: Collection): boolean {
99
- return collection.isTree && isCollectionIdle(collection);
100
- }
101
-
102
94
  function didRowDrift(collection: Collection, change: ItemsChange): boolean {
103
95
  const path = collection.pathAt(change.position - 1);
104
96
  const isExpanded = change.removed === 0 && change.added > 0;
@@ -114,7 +106,7 @@ function didRowDrift(collection: Collection, change: ItemsChange): boolean {
114
106
  }
115
107
 
116
108
  function observeExpansion(context: ExpansionContext, change: ItemsChange, onDrift: () => void): void {
117
- if (!isExpansionIdle(context.collection)) {
109
+ if (!context.collection.isTree || !isCollectionIdle(context.collection)) {
118
110
  return;
119
111
  }
120
112
 
@@ -128,7 +120,7 @@ function observeExpansion(context: ExpansionContext, change: ItemsChange, onDrif
128
120
 
129
121
  function useExpansion(options: ExpansionOptions): ItemsChangeHandler {
130
122
  const { collection, expandedIds, onExpandedChange } = options;
131
- const [last] = useState<LastExpansion>(newLastExpansion);
123
+ const [last] = useState<LastExpansion>(() => ({ key: "" }));
132
124
  const context: ExpansionContext = { collection, last, onExpandedChange };
133
125
 
134
126
  const markDrift = useControlledSync({
@@ -43,15 +43,7 @@ function trackPath(slots: SlotMap, path: string): void {
43
43
  return;
44
44
  }
45
45
 
46
- const level = slots.get(key.levelPath);
47
-
48
- if (level === undefined) {
49
- slots.set(key.levelPath, new Set([key.slot]));
50
-
51
- return;
52
- }
53
-
54
- level.add(key.slot);
46
+ slots.getOrInsertComputed(key.levelPath, () => new Set()).add(key.slot);
55
47
  }
56
48
 
57
49
  function trackPaths(paths: Iterable<string>): SlotMap {
@@ -3,7 +3,7 @@ import type { SlotMap } from "./slots.js";
3
3
  import type { VisibleOrder } from "./tree-order.js";
4
4
  import { encodePart } from "./keys.js";
5
5
  import { getSlotKey, trackPath, trackPaths } from "./slots.js";
6
- import { buildVisibleOrder } from "./tree-order.js";
6
+ import { walkVisible } from "./tree-order.js";
7
7
 
8
8
  type TreeExpansion = {
9
9
  index: CollectionIndex;
@@ -46,7 +46,7 @@ function createTreeExpansion(index: CollectionIndex): TreeExpansion {
46
46
  }
47
47
 
48
48
  function orderFor(expansion: TreeExpansion): VisibleOrder {
49
- expansion.order ??= buildVisibleOrder(expansion.index, expansion.slots);
49
+ expansion.order ??= walkVisible({ index: expansion.index, slots: expansion.slots });
50
50
 
51
51
  return expansion.order;
52
52
  }
@@ -183,10 +183,6 @@ function expandedPathsFor(index: CollectionIndex, ids: Set<string>): Set<string>
183
183
  return paths;
184
184
  }
185
185
 
186
- function buildVisibleOrder(index: CollectionIndex, slots: SlotMap): VisibleOrder {
187
- return walkVisible({ index, slots });
188
- }
189
-
190
186
  function findRows(index: CollectionIndex, slots: SlotMap, ids: Set<string>): MatchedRows {
191
187
  const positions: number[] = [];
192
188
  const found: string[] = [];
@@ -226,7 +222,6 @@ function findIds(index: CollectionIndex, slots: SlotMap, positions: Set<number>)
226
222
  }
227
223
 
228
224
  export {
229
- buildVisibleOrder,
230
225
  expandedPathsFor,
231
226
  findIds,
232
227
  findRows,