@gtkx/components 2.0.0-beta.11 → 2.0.0-beta.13

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 (92) hide show
  1. package/README.md +4 -4
  2. package/dist/column-view.d.ts.map +1 -1
  3. package/dist/column-view.js +8 -4
  4. package/dist/column-view.js.map +1 -1
  5. package/dist/combo-row.d.ts.map +1 -1
  6. package/dist/combo-row.js +3 -1
  7. package/dist/combo-row.js.map +1 -1
  8. package/dist/grid-view.d.ts.map +1 -1
  9. package/dist/grid-view.js +4 -3
  10. package/dist/grid-view.js.map +1 -1
  11. package/dist/internal/cells.d.ts +8 -9
  12. package/dist/internal/cells.d.ts.map +1 -1
  13. package/dist/internal/cells.js +98 -83
  14. package/dist/internal/cells.js.map +1 -1
  15. package/dist/internal/collection-index.d.ts +3 -5
  16. package/dist/internal/collection-index.d.ts.map +1 -1
  17. package/dist/internal/collection-index.js +3 -7
  18. package/dist/internal/collection-index.js.map +1 -1
  19. package/dist/internal/collection-model.d.ts +15 -4
  20. package/dist/internal/collection-model.d.ts.map +1 -1
  21. package/dist/internal/collection-model.js +154 -73
  22. package/dist/internal/collection-model.js.map +1 -1
  23. package/dist/internal/collection.d.ts +3 -3
  24. package/dist/internal/collection.d.ts.map +1 -1
  25. package/dist/internal/collection.js +11 -9
  26. package/dist/internal/collection.js.map +1 -1
  27. package/dist/internal/controlled-sync.d.ts +2 -1
  28. package/dist/internal/controlled-sync.d.ts.map +1 -1
  29. package/dist/internal/controlled-sync.js +6 -1
  30. package/dist/internal/controlled-sync.js.map +1 -1
  31. package/dist/internal/drop-down.d.ts +10 -4
  32. package/dist/internal/drop-down.d.ts.map +1 -1
  33. package/dist/internal/drop-down.js +61 -9
  34. package/dist/internal/drop-down.js.map +1 -1
  35. package/dist/internal/expansion.d.ts.map +1 -1
  36. package/dist/internal/expansion.js +46 -19
  37. package/dist/internal/expansion.js.map +1 -1
  38. package/dist/internal/first-header.d.ts +11 -0
  39. package/dist/internal/first-header.d.ts.map +1 -0
  40. package/dist/internal/first-header.js +21 -0
  41. package/dist/internal/first-header.js.map +1 -0
  42. package/dist/internal/keys.d.ts.map +1 -1
  43. package/dist/internal/level-splice.d.ts +27 -0
  44. package/dist/internal/level-splice.d.ts.map +1 -0
  45. package/dist/internal/level-splice.js +98 -0
  46. package/dist/internal/level-splice.js.map +1 -0
  47. package/dist/internal/selection.d.ts.map +1 -1
  48. package/dist/internal/selection.js +23 -16
  49. package/dist/internal/selection.js.map +1 -1
  50. package/dist/internal/tree-expansion.d.ts +3 -1
  51. package/dist/internal/tree-expansion.d.ts.map +1 -1
  52. package/dist/internal/tree-expansion.js +31 -2
  53. package/dist/internal/tree-expansion.js.map +1 -1
  54. package/dist/internal/tree-order.d.ts +2 -1
  55. package/dist/internal/tree-order.d.ts.map +1 -1
  56. package/dist/internal/tree-order.js +3 -2
  57. package/dist/internal/tree-order.js.map +1 -1
  58. package/dist/internal/use-collection.d.ts +1 -0
  59. package/dist/internal/use-collection.d.ts.map +1 -1
  60. package/dist/internal/use-collection.js +6 -2
  61. package/dist/internal/use-collection.js.map +1 -1
  62. package/dist/internal/use-widget-ref.d.ts.map +1 -1
  63. package/dist/list-item-factory.d.ts +1 -1
  64. package/dist/list-item-factory.d.ts.map +1 -1
  65. package/dist/list-item-factory.js +3 -3
  66. package/dist/list-item-factory.js.map +1 -1
  67. package/dist/list-view.d.ts.map +1 -1
  68. package/dist/list-view.js +9 -4
  69. package/dist/list-view.js.map +1 -1
  70. package/dist/types.d.ts +35 -12
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/types.js.map +1 -1
  73. package/package.json +10 -20
  74. package/src/column-view.tsx +8 -4
  75. package/src/combo-row.tsx +4 -1
  76. package/src/grid-view.tsx +3 -2
  77. package/src/internal/cells.tsx +174 -151
  78. package/src/internal/collection-index.ts +7 -14
  79. package/src/internal/collection-model.ts +211 -76
  80. package/src/internal/collection.ts +14 -13
  81. package/src/internal/controlled-sync.ts +8 -1
  82. package/src/internal/drop-down.tsx +112 -19
  83. package/src/internal/expansion.ts +52 -20
  84. package/src/internal/first-header.ts +23 -0
  85. package/src/internal/level-splice.ts +157 -0
  86. package/src/internal/selection.tsx +24 -19
  87. package/src/internal/tree-expansion.ts +43 -1
  88. package/src/internal/tree-order.ts +4 -10
  89. package/src/internal/use-collection.tsx +8 -2
  90. package/src/list-item-factory.tsx +2 -1
  91. package/src/list-view.tsx +10 -2
  92. package/src/types.ts +54 -24
@@ -1,16 +1,30 @@
1
- import type * as GObject from "@gtkx/gi/gobject";
1
+ import type * as Adw from "@gtkx/gi/adw";
2
2
  import type { ElementType, ReactNode, Ref } from "react";
3
- import { GtkLabel, GtkSignalListItemFactory } from "@gtkx/jsx/gtk";
3
+ import * as GObject from "@gtkx/gi/gobject";
4
+ import * as Gtk from "@gtkx/gi/gtk";
5
+ import type * as Pango from "@gtkx/gi/pango";
6
+ import { GtkBox, GtkImage, GtkLabel, GtkSignalListItemFactory } from "@gtkx/jsx/gtk";
7
+ import { useProperty } from "@gtkx/react";
8
+ import { useLatestRef } from "@gtkx/react/internal";
4
9
  import { omit, type Primitive } from "@gtkx/utils";
5
- import { useRef } from "react";
6
- import type { DropDownOwnProps, ListItemRenderArgs, ListItemRenderer } from "../types.js";
10
+ import { useLayoutEffect, useRef, useState } from "react";
11
+ import type { DropDownOwnProps, ListItemRenderer } from "../types.js";
7
12
  import { ItemPortals, useItemCells, useSectionHeader } from "./cells.js";
13
+ import { type SlotRef, slotRefFor } from "./collection-model.js";
8
14
  import { type Collection, isCollectionIdle } from "./collection.js";
9
15
  import { useControlledSync } from "./controlled-sync.js";
16
+ import { showFirstHeader } from "./first-header.js";
10
17
  import { useCollectionData } from "./use-collection.js";
11
18
  import { useWidgetRef } from "./use-widget-ref.js";
12
19
 
13
- type SelectableWidget = GObject.Object & { getSelected: () => number; selected: number };
20
+ type SelectableWidget = Gtk.DropDown | Adw.ComboRow;
21
+
22
+ /** Label settings of the widget's own default item factory, kept by the default renderers. */
23
+ type ItemLabelProps = {
24
+ ellipsize?: Pango.EllipsizeMode;
25
+ maxWidthChars?: number;
26
+ widthChars?: number;
27
+ };
14
28
 
15
29
  type DropDownBaseProps = DropDownOwnProps<unknown, unknown> & {
16
30
  onNotifySelected?: ((value: number | null, self: SelectableWidget) => void) | null | undefined;
@@ -25,6 +39,7 @@ type SelectionOptions = {
25
39
 
26
40
  type NotifySelectedHandler = NonNullable<DropDownBaseProps["onNotifySelected"]>;
27
41
  type ApplyState = { isApplying: boolean; isReady: boolean };
42
+ type ShownSelection = { ref: SlotRef | null; text: string };
28
43
 
29
44
  type NotifyContext = {
30
45
  options: SelectionOptions;
@@ -40,6 +55,7 @@ type KnownSelection = {
40
55
 
41
56
  const DROP_DOWN_PROPS: string[] = [
42
57
  "component",
58
+ "itemLabel",
43
59
  "items",
44
60
  "sections",
45
61
  "selectedId",
@@ -55,15 +71,84 @@ function newApplyState(): ApplyState {
55
71
  return { isApplying: false, isReady: false };
56
72
  }
57
73
 
58
- const defaultRenderItem = ({ item }: ListItemRenderArgs<Primitive>): ReactNode =>
59
- item == null ? null : <GtkLabel>{String(item)}</GtkLabel>;
60
- const faceRenderer = (props: DropDownBaseProps): ListItemRenderer<never> => props.renderItem ?? defaultRenderItem;
74
+ const defaultRenderer =
75
+ (itemLabel: ItemLabelProps): ListItemRenderer<Primitive> =>
76
+ ({ item }) =>
77
+ item == null ? null : <GtkLabel {...itemLabel}>{String(item)}</GtkLabel>;
78
+ const faceRenderer = (props: DropDownBaseProps, itemLabel: ItemLabelProps): ListItemRenderer<never> =>
79
+ props.renderItem ?? defaultRenderer(itemLabel);
80
+
81
+ const selectionMarkedRenderer =
82
+ (selected: number | undefined, itemLabel: ItemLabelProps): ListItemRenderer<Primitive> =>
83
+ ({ item, index }) => (
84
+ <GtkBox>
85
+ <GtkLabel xalign={0} {...itemLabel}>
86
+ {item == null ? "" : String(item)}
87
+ </GtkLabel>
88
+ <GtkImage
89
+ iconName="object-select-symbolic"
90
+ accessibleRole={Gtk.AccessibleRole.PRESENTATION}
91
+ opacity={index === selected ? 1 : 0}
92
+ />
93
+ </GtkBox>
94
+ );
61
95
 
62
- const resolvePosition = (
63
- widget: SelectableWidget,
64
- collection: Collection,
65
- selectedId: string | undefined,
66
- ): number => {
96
+ const popupRenderer = (
97
+ props: DropDownBaseProps,
98
+ selected: number | undefined,
99
+ itemLabel: ItemLabelProps,
100
+ ): ListItemRenderer<never> | null =>
101
+ props.renderListItem ?? (props.renderItem == null ? selectionMarkedRenderer(selected, itemLabel) : null);
102
+
103
+ const textOf = (value: unknown): string => {
104
+ switch (typeof value) {
105
+ case "string":
106
+ return value;
107
+ case "number":
108
+ case "bigint":
109
+ case "boolean":
110
+ return String(value);
111
+ case "symbol":
112
+ return value.toString();
113
+ default:
114
+ return "";
115
+ }
116
+ };
117
+
118
+ const slotText = (collection: Collection, ref: SlotRef | null): string =>
119
+ ref === null ? "" : textOf(collection.itemAt(ref)?.value);
120
+
121
+ /**
122
+ * AdwComboRow reads the selected item's text for its subtitle only when the selected item or the expression changes.
123
+ * Slots keep their objects when an item's value changes in place, so the expression is replaced when the selected
124
+ * item's text changes without a selection change.
125
+ */
126
+ const useItemTextExpression = (widget: SelectableWidget | null, collection: Collection): Gtk.Expression => {
127
+ const latest = useLatestRef(collection);
128
+ const shown = useRef<ShownSelection>({ ref: null, text: "" });
129
+ const createExpression = (): Gtk.Expression =>
130
+ Gtk.ClosureExpression.new(
131
+ GObject.TYPE_STRING,
132
+ (object: GObject.Object | null) => slotText(latest.current, slotRefFor(object)),
133
+ null,
134
+ );
135
+ const [expression, setExpression] = useState(createExpression);
136
+
137
+ useLayoutEffect(() => {
138
+ const ref = slotRefFor(widget?.getSelectedItem() ?? null);
139
+ const text = slotText(collection, ref);
140
+ const isRenamed = ref !== null && ref === shown.current.ref && text !== shown.current.text;
141
+ shown.current = { ref, text };
142
+
143
+ if (isRenamed) {
144
+ setExpression(createExpression());
145
+ }
146
+ });
147
+
148
+ return expression;
149
+ };
150
+
151
+ const resolvePosition = (widget: SelectableWidget, collection: Collection, selectedId: string | undefined): number => {
67
152
  if (selectedId === undefined) {
68
153
  return widget.getSelected();
69
154
  }
@@ -179,31 +264,39 @@ const useDropDownSelection = (options: SelectionOptions): NotifySelectedHandler
179
264
  };
180
265
  };
181
266
 
182
- function DropDownBase(props: DropDownBaseProps & { component: ElementType }): ReactNode {
183
- const { component: Component, items, sections, renderListItem, renderHeader, ref } = props;
267
+ function DropDownBase(props: DropDownBaseProps & { component: ElementType; itemLabel?: ItemLabelProps }): ReactNode {
268
+ const { component: Component, itemLabel = {}, items, sections, renderHeader, ref } = props;
184
269
  const rest = omit(props, DROP_DOWN_PROPS);
185
270
  const [widget, refCallback] = useWidgetRef<SelectableWidget>(ref);
186
- const { collection, model } = useCollectionData({ items, sections, isFlat: true });
271
+ const { collection, model } = useCollectionData({
272
+ items,
273
+ sections,
274
+ isFlat: true,
275
+ onSectionsFilled: () => showFirstHeader(widget),
276
+ });
187
277
  const faceCells = useItemCells({ width: -1, height: -1 });
188
278
  const listCells = useItemCells({ width: -1, height: -1 });
189
279
  const header = useSectionHeader(renderHeader, collection, { width: -1, height: -1 });
190
280
  const handleNotifySelected = useDropDownSelection({ widget, collection, props });
281
+ const renderListItem = popupRenderer(props, useProperty(widget, "selected"), itemLabel);
282
+ const expression = useItemTextExpression(widget, collection);
191
283
 
192
284
  return (
193
285
  <>
194
286
  <Component
195
287
  ref={refCallback}
196
288
  model={model}
289
+ expression={expression}
197
290
  factory={<GtkSignalListItemFactory {...faceCells.handlers} />}
198
- {...(renderListItem != null && {
291
+ {...(renderListItem !== null && {
199
292
  listFactory: <GtkSignalListItemFactory {...listCells.handlers} />,
200
293
  })}
201
294
  {...header.factoryProps}
202
295
  {...rest}
203
296
  onNotifySelected={handleNotifySelected}
204
297
  />
205
- <ItemPortals registry={faceCells} render={faceRenderer(props)} collection={collection} />
206
- {renderListItem != null && (
298
+ <ItemPortals registry={faceCells} render={faceRenderer(props, itemLabel)} collection={collection} />
299
+ {renderListItem !== null && (
207
300
  <ItemPortals registry={listCells} render={renderListItem} collection={collection} />
208
301
  )}
209
302
  {header.portals}
@@ -3,7 +3,7 @@ import type { Collection } from "./collection.js";
3
3
  import type { TreeExpansion } from "./tree-expansion.js";
4
4
  import type { VisibleOrder, WalkOptions } from "./tree-order.js";
5
5
  import { isCollectionIdle } from "./collection.js";
6
- import { useControlledSync } from "./controlled-sync.js";
6
+ import { hasSameIds, useControlledSync } from "./controlled-sync.js";
7
7
  import { joinParts } from "./keys.js";
8
8
  import { trackPaths } from "./slots.js";
9
9
  import { adoptOrder, markExpanded, orderFor } from "./tree-expansion.js";
@@ -34,29 +34,44 @@ function hasSameExpansion(expanded: Set<string>, wanted: Set<string>): boolean {
34
34
  return expanded.size === wanted.size && wanted.isSubsetOf(expanded);
35
35
  }
36
36
 
37
- function toggleOptions(collection: Collection, wanted: Set<string>): WalkOptions {
37
+ function collapsingPositions(expansion: TreeExpansion, wanted: Set<string>): number[] {
38
+ const positions: number[] = [];
39
+
40
+ walkVisible({
41
+ index: expansion.index,
42
+ slots: expansion.slots,
43
+ marks: trackPaths(expansion.expanded.difference(wanted)),
44
+ visit: (row) => {
45
+ if (row.isMarked) {
46
+ positions.push(row.position);
47
+ }
48
+ },
49
+ });
50
+
51
+ return positions;
52
+ }
53
+
54
+ function expandOptions(collection: Collection, wanted: Set<string>): WalkOptions {
38
55
  const { expansion } = collection;
39
56
 
40
57
  return {
41
58
  index: expansion.index,
42
59
  slots: trackPaths(wanted),
43
- marks: trackPaths(wanted.symmetricDifference(expansion.expanded)),
60
+ marks: trackPaths(wanted.difference(expansion.expanded)),
44
61
  visit: (row) => {
45
62
  if (row.isMarked) {
46
- collection.rowAt(row.position)?.setExpanded(row.isOpen);
63
+ collection.rowAt(row.position)?.setExpanded(true);
47
64
  }
48
65
  },
49
66
  };
50
67
  }
51
68
 
52
- function walkApplying(expansion: TreeExpansion, options: WalkOptions): VisibleOrder {
53
- expansion.isApplying = true;
54
-
55
- try {
56
- return walkVisible(options);
57
- } finally {
58
- expansion.isApplying = false;
69
+ function toggleRows(collection: Collection, wanted: Set<string>): VisibleOrder {
70
+ for (const position of collapsingPositions(collection.expansion, wanted).toReversed()) {
71
+ collection.rowAt(position)?.setExpanded(false);
59
72
  }
73
+
74
+ return walkVisible(expandOptions(collection, wanted));
60
75
  }
61
76
 
62
77
  function applyWanted(collection: Collection, expandedIds: string[]): void {
@@ -67,19 +82,29 @@ function applyWanted(collection: Collection, expandedIds: string[]): void {
67
82
  return;
68
83
  }
69
84
 
70
- adoptOrder(expansion, walkApplying(expansion, toggleOptions(collection, wanted)));
85
+ expansion.isApplying = true;
86
+
87
+ try {
88
+ adoptOrder(expansion, toggleRows(collection, wanted));
89
+ } finally {
90
+ expansion.isApplying = false;
91
+ }
92
+ }
93
+
94
+ function rememberExpansion(context: ExpansionContext, expandedPaths: string[]): boolean {
95
+ const key = joinParts(expandedPaths);
96
+ const isKnown = context.last.key === key;
97
+ context.last.key = key;
98
+
99
+ return isKnown;
71
100
  }
72
101
 
73
102
  function reportExpansion(context: ExpansionContext): void {
74
103
  const { expandedIds, expandedPaths } = orderFor(context.collection.expansion);
75
- const key = joinParts(expandedPaths);
76
104
 
77
- if (context.last.key === key) {
78
- return;
105
+ if (!rememberExpansion(context, expandedPaths)) {
106
+ context.onExpandedChange?.([...expandedIds]);
79
107
  }
80
-
81
- context.last.key = key;
82
- context.onExpandedChange?.([...expandedIds]);
83
108
  }
84
109
 
85
110
  function applyControlledExpansion(context: ExpansionContext, expandedIds: string[] | null | undefined): void {
@@ -87,8 +112,15 @@ function applyControlledExpansion(context: ExpansionContext, expandedIds: string
87
112
  return;
88
113
  }
89
114
 
90
- applyWanted(context.collection, expandedIds ?? []);
91
- reportExpansion(context);
115
+ const requested = expandedIds ?? [];
116
+ applyWanted(context.collection, requested);
117
+ const order = orderFor(context.collection.expansion);
118
+
119
+ if (hasSameIds(order.expandedIds, requested)) {
120
+ rememberExpansion(context, order.expandedPaths);
121
+ } else {
122
+ reportExpansion(context);
123
+ }
92
124
  }
93
125
 
94
126
  function didRowDrift(collection: Collection, change: ItemsChange): boolean {
@@ -0,0 +1,23 @@
1
+ import type * as Adw from "@gtkx/gi/adw";
2
+ import * as Gtk from "@gtkx/gi/gtk";
3
+
4
+ type SectionedWidget = Gtk.ListView | Gtk.ColumnView | Gtk.DropDown | Adw.ComboRow;
5
+
6
+ /**
7
+ * TODO: GTK 4.22 anchors a list whose model fills after the list holds it at the first row, scrolled past that
8
+ * row's section header. Scrolling a view to its first row, or handing a drop-down its filled model again, starts
9
+ * it at the header. Remove once a GTK release fixes the anchor and GTKX requires it.
10
+ */
11
+ function showFirstHeader(widget: SectionedWidget | null): void {
12
+ if (widget instanceof Gtk.ListView) {
13
+ widget.scrollTo(0, Gtk.ListScrollFlags.NONE, null);
14
+ } else if (widget instanceof Gtk.ColumnView) {
15
+ widget.scrollTo(0, null, Gtk.ListScrollFlags.NONE, null);
16
+ } else if (widget !== null) {
17
+ const model = widget.getModel();
18
+ widget.setModel(null);
19
+ widget.setModel(model);
20
+ }
21
+ }
22
+
23
+ export { showFirstHeader };
@@ -0,0 +1,157 @@
1
+ import { joinParts } from "./keys.js";
2
+
3
+ type Splice = {
4
+ start: number;
5
+ removed: number;
6
+ added: number;
7
+ };
8
+
9
+ type KeyAt = (slot: number) => string;
10
+
11
+ type LevelKeys = {
12
+ previousLength: number;
13
+ nextLength: number;
14
+ previousKey: KeyAt;
15
+ nextKey: KeyAt;
16
+ };
17
+
18
+ type Relocate = (slot: number) => number | undefined;
19
+
20
+ const NO_MOVES: Map<number, number> = new Map();
21
+ const NO_SPLICE: Splice = { start: 0, removed: 0, added: 0 };
22
+ const MOVE_SEARCH_LIMIT = 1024;
23
+
24
+ function findSplice(keys: LevelKeys): Splice {
25
+ const { previousLength, nextLength, previousKey, nextKey } = keys;
26
+ const overlap = Math.min(previousLength, nextLength);
27
+ let start = 0;
28
+
29
+ while (start < overlap && previousKey(start) === nextKey(start)) {
30
+ start += 1;
31
+ }
32
+
33
+ let shared = 0;
34
+
35
+ while (shared < overlap - start && previousKey(previousLength - 1 - shared) === nextKey(nextLength - 1 - shared)) {
36
+ shared += 1;
37
+ }
38
+
39
+ return { start, removed: previousLength - start - shared, added: nextLength - start - shared };
40
+ }
41
+
42
+ function hasSplice(splice: Splice): boolean {
43
+ return splice.removed > 0 || splice.added > 0;
44
+ }
45
+
46
+ function isRemoved(splice: Splice, slot: number): boolean {
47
+ return slot >= splice.start && slot < splice.start + splice.removed;
48
+ }
49
+
50
+ function occurrenceKeys(key: KeyAt, from: number, length: number, wanted: Set<string>): Map<number, string> {
51
+ const counts: Map<string, number> = new Map();
52
+ const keys: Map<number, string> = new Map();
53
+
54
+ for (let slot = from; slot < from + length; slot++) {
55
+ const id = key(slot);
56
+
57
+ if (!wanted.has(id)) {
58
+ continue;
59
+ }
60
+
61
+ const occurrence = counts.get(id) ?? 0;
62
+ counts.set(id, occurrence + 1);
63
+ keys.set(slot, joinParts([id, String(occurrence)]));
64
+ }
65
+
66
+ return keys;
67
+ }
68
+
69
+ function matchMoves(keys: LevelKeys, splice: Splice, candidates: Iterable<number>): Map<number, number> {
70
+ const removed = Iterator.from(candidates)
71
+ .filter((slot) => isRemoved(splice, slot))
72
+ .toArray();
73
+
74
+ if (removed.length === 0 || splice.added === 0) {
75
+ return NO_MOVES;
76
+ }
77
+
78
+ const wanted = new Set(removed.map((slot) => keys.previousKey(slot)));
79
+ const previous = occurrenceKeys(keys.previousKey, splice.start, splice.removed, wanted);
80
+ const bySlotKey: Map<string, number> = new Map();
81
+
82
+ for (const slot of removed) {
83
+ const key = previous.get(slot);
84
+
85
+ if (key !== undefined) {
86
+ bySlotKey.set(key, slot);
87
+ }
88
+ }
89
+
90
+ const moves: Map<number, number> = new Map();
91
+
92
+ for (const [slot, key] of occurrenceKeys(keys.nextKey, splice.start, splice.added, wanted)) {
93
+ const origin = bySlotKey.get(key);
94
+
95
+ if (origin !== undefined) {
96
+ moves.set(origin, slot);
97
+ }
98
+ }
99
+
100
+ return moves;
101
+ }
102
+
103
+ function relocateSlot(splice: Splice, slot: number, moves: Map<number, number> = NO_MOVES): number | undefined {
104
+ if (slot < splice.start) {
105
+ return slot;
106
+ }
107
+
108
+ if (isRemoved(splice, slot)) {
109
+ return moves.get(slot);
110
+ }
111
+
112
+ return slot + splice.added - splice.removed;
113
+ }
114
+
115
+ function relocateEntries<V>(entries: Map<number, V>, relocate: Relocate, onDrop: (value: V) => void): Map<number, V> {
116
+ const relocated: Map<number, V> = new Map();
117
+
118
+ for (const [slot, value] of entries) {
119
+ const next = relocate(slot);
120
+
121
+ if (next === undefined) {
122
+ onDrop(value);
123
+ } else {
124
+ relocated.set(next, value);
125
+ }
126
+ }
127
+
128
+ return relocated;
129
+ }
130
+
131
+ /**
132
+ * GTK looks up every added item for a moved row whenever a splice removes a row it tracks, such as the scroll
133
+ * anchor or the focus, which makes every lazily created item real. Past a limit, the overlapping slots are
134
+ * rewritten in place instead and only the difference is spliced, so rows that move there remount.
135
+ */
136
+ function searchableSplice(splice: Splice, addedRows: number): Splice {
137
+ if (addedRows <= MOVE_SEARCH_LIMIT) {
138
+ return splice;
139
+ }
140
+
141
+ const rewritten = Math.min(splice.removed, splice.added);
142
+
143
+ return { start: splice.start + rewritten, removed: splice.removed - rewritten, added: splice.added - rewritten };
144
+ }
145
+
146
+ export {
147
+ NO_SPLICE,
148
+ findSplice,
149
+ hasSplice,
150
+ matchMoves,
151
+ relocateEntries,
152
+ relocateSlot,
153
+ searchableSplice,
154
+ type LevelKeys,
155
+ type Relocate,
156
+ type Splice,
157
+ };
@@ -6,7 +6,7 @@ import type { Collection } from "./collection.js";
6
6
  import type { ItemsChangeHandler } from "./expansion.js";
7
7
  import type { MatchedRows } from "./tree-order.js";
8
8
  import { isCollectionIdle } from "./collection.js";
9
- import { useControlledSync } from "./controlled-sync.js";
9
+ import { hasSameIds, useControlledSync } from "./controlled-sync.js";
10
10
  import { joinParts } from "./keys.js";
11
11
 
12
12
  type SelectionOptions = {
@@ -89,10 +89,6 @@ function plannedSelection(context: SelectionContext, ids: string[]): MatchedRows
89
89
 
90
90
  const rows = collection.rowsFor(ids);
91
91
 
92
- if (ids.length > 0 && rows.positions.length === 0) {
93
- return null;
94
- }
95
-
96
92
  if (selection instanceof Gtk.SingleSelection) {
97
93
  return { positions: rows.positions.slice(0, 1), ids: rows.ids.slice(0, 1) };
98
94
  }
@@ -150,16 +146,27 @@ function applySelection(selection: Gtk.SelectionModel, positions: number[]): Gtk
150
146
  return selected;
151
147
  }
152
148
 
153
- function reportSelection(context: SelectionContext, ids: string[]): void {
149
+ function rememberSelection(context: SelectionContext, ids: string[]): boolean {
154
150
  const key = joinParts(ids);
151
+ const isKnown = context.last.selection === context.selection && context.last.key === key;
152
+ context.last.selection = context.selection;
153
+ context.last.key = key;
155
154
 
156
- if (context.last.selection === context.selection && context.last.key === key) {
157
- return;
155
+ return isKnown;
156
+ }
157
+
158
+ function reportSelection(context: SelectionContext, ids: string[]): void {
159
+ if (!rememberSelection(context, ids)) {
160
+ context.onSelectionChanged?.(ids);
158
161
  }
162
+ }
159
163
 
160
- context.last.selection = context.selection;
161
- context.last.key = key;
162
- context.onSelectionChanged?.(ids);
164
+ function settleSelection(context: SelectionContext, ids: string[], selectedIds: string[]): void {
165
+ if (hasSameIds(ids, selectedIds)) {
166
+ rememberSelection(context, ids);
167
+ } else {
168
+ reportSelection(context, ids);
169
+ }
163
170
  }
164
171
 
165
172
  function hasSelectionDrifted(actual: number[], planned: number[]): boolean {
@@ -198,15 +205,16 @@ function applyControlledSelection(context: SelectionContext, selectedIds: string
198
205
  return;
199
206
  }
200
207
 
201
- const planned = plannedSelection(context, selectedIds ?? []);
208
+ const requested = selectedIds ?? [];
209
+ const planned = plannedSelection(context, requested);
202
210
 
203
211
  if (planned !== null && didApplyPlanned(context, selection, planned.positions)) {
204
- reportSelection(context, planned.ids);
212
+ settleSelection(context, planned.ids, requested);
205
213
 
206
214
  return;
207
215
  }
208
216
 
209
- reportSelection(context, collection.idsAt(selectedPositions(selection)));
217
+ settleSelection(context, collection.idsAt(selectedPositions(selection)), requested);
210
218
  }
211
219
 
212
220
  function reportDrift(
@@ -229,16 +237,13 @@ function observeSelection(
229
237
  ): void {
230
238
  const { selection, collection, state } = context;
231
239
 
232
- if (selection === null || state.isApplying) {
240
+ if (selection === null || state.isApplying || !isCollectionIdle(collection)) {
233
241
  return;
234
242
  }
235
243
 
236
244
  const positions = selectedPositions(selection);
237
245
  reportSelection(context, collection.idsAt(positions));
238
-
239
- if (isCollectionIdle(collection)) {
240
- reportDrift(context, selectedIds, positions, onDrift);
241
- }
246
+ reportDrift(context, selectedIds, positions, onDrift);
242
247
  }
243
248
 
244
249
  function newLastSelection(): LastSelection {
@@ -1,7 +1,8 @@
1
1
  import type { CollectionIndex } from "./collection-index.js";
2
2
  import type { SlotMap } from "./slots.js";
3
3
  import type { VisibleOrder } from "./tree-order.js";
4
- import { encodePart } from "./keys.js";
4
+ import type { Relocate } from "./level-splice.js";
5
+ import { decodePartAt, encodePart } from "./keys.js";
5
6
  import { getSlotKey, trackPath, trackPaths } from "./slots.js";
6
7
  import { walkVisible } from "./tree-order.js";
7
8
 
@@ -75,6 +76,46 @@ function pruneSlots(expansion: TreeExpansion, levelPath: string, isPruned: (slot
75
76
  expansion.order = null;
76
77
  }
77
78
 
79
+ function relocatePath(path: string, levelPath: string, relocate: Relocate): string | null {
80
+ if (path.length === levelPath.length || !path.startsWith(levelPath)) {
81
+ return path;
82
+ }
83
+
84
+ const part = decodePartAt(path, levelPath.length);
85
+
86
+ if (part === null) {
87
+ return path;
88
+ }
89
+
90
+ const slot = relocate(Number(part));
91
+
92
+ if (slot === undefined) {
93
+ return null;
94
+ }
95
+
96
+ return levelPath + encodePart(String(slot)) + path.slice(levelPath.length + encodePart(part).length);
97
+ }
98
+
99
+ function relocateSlots(expansion: TreeExpansion, levelPath: string, relocate: Relocate): void {
100
+ if (expansion.expanded.size === 0) {
101
+ return;
102
+ }
103
+
104
+ const expanded: Set<string> = new Set();
105
+
106
+ for (const path of expansion.expanded) {
107
+ const relocated = relocatePath(path, levelPath, relocate);
108
+
109
+ if (relocated !== null) {
110
+ expanded.add(relocated);
111
+ }
112
+ }
113
+
114
+ expansion.expanded = expanded;
115
+ expansion.slots = trackPaths(expanded);
116
+ expansion.order = null;
117
+ }
118
+
78
119
  function markExpanded(expansion: TreeExpansion, path: string, isExpanded: boolean): void {
79
120
  if (isExpanded) {
80
121
  expansion.expanded.add(path);
@@ -99,5 +140,6 @@ export {
99
140
  markExpanded,
100
141
  orderFor,
101
142
  pruneSlots,
143
+ relocateSlots,
102
144
  type TreeExpansion,
103
145
  };
@@ -12,6 +12,7 @@ type VisibleRow = {
12
12
  type VisibleOrder = {
13
13
  expandedPaths: string[];
14
14
  expandedIds: string[];
15
+ positions: Map<string, number>;
15
16
  };
16
17
 
17
18
  type MatchedRows = {
@@ -39,6 +40,7 @@ function walkLevel(options: WalkOptions, level: Level, state: WalkState): void {
39
40
 
40
41
  for (const [slot, item] of level.items.entries()) {
41
42
  const isOpen = open?.has(slot) ?? false;
43
+ state.order.positions.set(item.id, state.position);
42
44
  options.visit?.({ item, position: state.position, isOpen, isMarked: marked?.has(slot) ?? false });
43
45
  state.position += 1;
44
46
 
@@ -57,7 +59,7 @@ function walkLevel(options: WalkOptions, level: Level, state: WalkState): void {
57
59
  }
58
60
 
59
61
  function walkVisible(options: WalkOptions): VisibleOrder {
60
- const state: WalkState = { position: 0, order: { expandedPaths: [], expandedIds: [] } };
62
+ const state: WalkState = { position: 0, order: { expandedPaths: [], expandedIds: [], positions: new Map() } };
61
63
 
62
64
  for (const level of options.index.groups) {
63
65
  walkLevel(options, level, state);
@@ -133,12 +135,4 @@ function findIds(index: CollectionIndex, slots: SlotMap, positions: Set<number>)
133
135
  return found;
134
136
  }
135
137
 
136
- export {
137
- expandedPathsFor,
138
- findIds,
139
- findRows,
140
- walkVisible,
141
- type MatchedRows,
142
- type VisibleOrder,
143
- type WalkOptions,
144
- };
138
+ export { expandedPathsFor, findIds, findRows, walkVisible, type MatchedRows, type VisibleOrder, type WalkOptions };