@gtkx/components 1.0.0-rc.4 → 1.0.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.
Files changed (86) hide show
  1. package/README.md +5 -5
  2. package/dist/column-view.d.ts +2 -2
  3. package/dist/column-view.d.ts.map +1 -1
  4. package/dist/column-view.js +14 -12
  5. package/dist/column-view.js.map +1 -1
  6. package/dist/grid-view.js +1 -1
  7. package/dist/grid-view.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/internal/cells.d.ts +31 -23
  12. package/dist/internal/cells.d.ts.map +1 -1
  13. package/dist/internal/cells.js +168 -77
  14. package/dist/internal/cells.js.map +1 -1
  15. package/dist/internal/collection-index.d.ts +6 -10
  16. package/dist/internal/collection-index.d.ts.map +1 -1
  17. package/dist/internal/collection-index.js +32 -69
  18. package/dist/internal/collection-index.js.map +1 -1
  19. package/dist/internal/collection-model.d.ts +23 -2
  20. package/dist/internal/collection-model.d.ts.map +1 -1
  21. package/dist/internal/collection-model.js +210 -95
  22. package/dist/internal/collection-model.js.map +1 -1
  23. package/dist/internal/collection.d.ts +8 -11
  24. package/dist/internal/collection.d.ts.map +1 -1
  25. package/dist/internal/collection.js +38 -49
  26. package/dist/internal/collection.js.map +1 -1
  27. package/dist/internal/controlled-sync.d.ts +11 -0
  28. package/dist/internal/controlled-sync.d.ts.map +1 -0
  29. package/dist/internal/controlled-sync.js +19 -0
  30. package/dist/internal/controlled-sync.js.map +1 -0
  31. package/dist/internal/drop-down.d.ts.map +1 -1
  32. package/dist/internal/drop-down.js +27 -15
  33. package/dist/internal/drop-down.js.map +1 -1
  34. package/dist/internal/expansion.d.ts +3 -2
  35. package/dist/internal/expansion.d.ts.map +1 -1
  36. package/dist/internal/expansion.js +80 -47
  37. package/dist/internal/expansion.js.map +1 -1
  38. package/dist/internal/keys.d.ts +5 -0
  39. package/dist/internal/keys.d.ts.map +1 -0
  40. package/dist/internal/keys.js +13 -0
  41. package/dist/internal/keys.js.map +1 -0
  42. package/dist/internal/selection.d.ts +3 -2
  43. package/dist/internal/selection.d.ts.map +1 -1
  44. package/dist/internal/selection.js +68 -35
  45. package/dist/internal/selection.js.map +1 -1
  46. package/dist/internal/slots.d.ts +10 -0
  47. package/dist/internal/slots.d.ts.map +1 -0
  48. package/dist/internal/slots.js +43 -0
  49. package/dist/internal/slots.js.map +1 -0
  50. package/dist/internal/tree-expansion.d.ts +20 -0
  51. package/dist/internal/tree-expansion.d.ts.map +1 -0
  52. package/dist/internal/tree-expansion.js +68 -0
  53. package/dist/internal/tree-expansion.js.map +1 -0
  54. package/dist/internal/tree-order.d.ts +27 -0
  55. package/dist/internal/tree-order.d.ts.map +1 -0
  56. package/dist/internal/tree-order.js +71 -0
  57. package/dist/internal/tree-order.js.map +1 -0
  58. package/dist/internal/use-collection.d.ts +1 -1
  59. package/dist/internal/use-collection.d.ts.map +1 -1
  60. package/dist/internal/use-collection.js +4 -4
  61. package/dist/internal/use-collection.js.map +1 -1
  62. package/dist/list-view.d.ts.map +1 -1
  63. package/dist/list-view.js +6 -6
  64. package/dist/list-view.js.map +1 -1
  65. package/dist/types.d.ts +70 -21
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js.map +1 -1
  68. package/package.json +7 -7
  69. package/src/column-view.tsx +28 -25
  70. package/src/grid-view.tsx +1 -1
  71. package/src/index.ts +3 -0
  72. package/src/internal/cells.tsx +339 -117
  73. package/src/internal/collection-index.ts +44 -92
  74. package/src/internal/collection-model.ts +259 -107
  75. package/src/internal/collection.ts +45 -67
  76. package/src/internal/controlled-sync.ts +34 -0
  77. package/src/internal/drop-down.tsx +41 -20
  78. package/src/internal/expansion.ts +104 -67
  79. package/src/internal/keys.ts +18 -0
  80. package/src/internal/selection.tsx +100 -51
  81. package/src/internal/slots.ts +67 -0
  82. package/src/internal/tree-expansion.ts +110 -0
  83. package/src/internal/tree-order.ts +138 -0
  84. package/src/internal/use-collection.tsx +5 -5
  85. package/src/list-view.tsx +14 -10
  86. package/src/types.ts +76 -19
@@ -0,0 +1,34 @@
1
+ import { startTransition, useEffectEvent, useLayoutEffect, useState } from "react";
2
+ import type { Collection } from "./collection.js";
3
+
4
+ type ControlledIds = string[] | null | undefined;
5
+
6
+ type ControlledSyncOptions = {
7
+ ids: ControlledIds;
8
+ collection: Collection;
9
+ widget?: object | null | undefined;
10
+ apply: (ids: ControlledIds) => void;
11
+ };
12
+
13
+ function useControlledSync(options: ControlledSyncOptions): () => void {
14
+ const { ids, collection, widget } = options;
15
+ const [drift, setDrift] = useState(0);
16
+
17
+ const markDrift = (): void => {
18
+ startTransition(() => {
19
+ setDrift((count) => count + 1);
20
+ });
21
+ };
22
+
23
+ const apply = useEffectEvent((next: ControlledIds): void => {
24
+ options.apply(next);
25
+ });
26
+
27
+ useLayoutEffect(() => {
28
+ apply(ids);
29
+ }, [widget, collection, ids, drift]);
30
+
31
+ return markDrift;
32
+ }
33
+
34
+ export { useControlledSync };
@@ -1,12 +1,11 @@
1
1
  import type * as GObject from "@gtkx/gi/gobject";
2
2
  import type { ElementType, ReactNode, Ref } from "react";
3
3
  import { GtkLabel, GtkSignalListItemFactory } from "@gtkx/jsx/gtk";
4
- import { applyWrite } from "@gtkx/react/internal";
5
4
  import { omit } from "@gtkx/utils";
6
- import { useEffectEvent, useLayoutEffect, useRef } from "react";
5
+ import { useEffectEvent, useLayoutEffect, useRef, useState } from "react";
7
6
  import type { DropDownOwnProps, ListItemRenderArgs, ListItemRenderer } from "../types.js";
8
7
  import type { Collection } from "./collection.js";
9
- import { HeaderPortals, ItemPortals, useHeaderCells, useItemCells } from "./cells.js";
8
+ import { ItemPortals, useItemCells, useSectionHeader } from "./cells.js";
10
9
  import { useCollectionData } from "./use-collection.js";
11
10
  import { useWidgetRef } from "./use-widget-ref.js";
12
11
 
@@ -24,6 +23,13 @@ type SelectionOptions = {
24
23
  };
25
24
 
26
25
  type NotifySelectedHandler = NonNullable<DropDownBaseProps["onNotifySelected"]>;
26
+ type ApplyState = { isApplying: boolean };
27
+
28
+ type NotifyContext = {
29
+ options: SelectionOptions;
30
+ known: { current: string | null };
31
+ state: ApplyState;
32
+ };
27
33
 
28
34
  type KnownSelection = {
29
35
  known: { current: string | null };
@@ -43,6 +49,10 @@ const DROP_DOWN_PROPS: string[] = [
43
49
  "ref",
44
50
  ];
45
51
 
52
+ function newApplyState(): ApplyState {
53
+ return { isApplying: false };
54
+ }
55
+
46
56
  const describeValue = (value: unknown): string => {
47
57
  if (typeof value === "string") {
48
58
  return value;
@@ -61,9 +71,6 @@ const defaultItemContent = (value: unknown): ReactNode =>
61
71
  const defaultRenderItem = ({ item }: ListItemRenderArgs<unknown>): ReactNode => defaultItemContent(item);
62
72
  const faceRenderer = (props: DropDownBaseProps): ListItemRenderer<never> => props.renderItem ?? defaultRenderItem;
63
73
 
64
- const listRenderer = (props: DropDownBaseProps): ListItemRenderer<never> =>
65
- props.renderListItem ?? props.renderItem ?? defaultRenderItem;
66
-
67
74
  const resolvePosition = (
68
75
  widget: SelectableWidget,
69
76
  collection: Collection,
@@ -110,16 +117,32 @@ const reportSelectedNotify = (options: SelectionOptions, tracker: KnownSelection
110
117
  reportKnownSelection(tracker, collection.idAt(widget.getSelected()));
111
118
  };
112
119
 
113
- const applySelectedPosition = (widget: SelectableWidget, position: number): void => {
114
- applyWrite(() => {
120
+ const applySelectedPosition = (widget: SelectableWidget, position: number, state: ApplyState): void => {
121
+ state.isApplying = true;
122
+
123
+ try {
115
124
  widget.selected = position;
116
- });
125
+ } finally {
126
+ state.isApplying = false;
127
+ }
128
+ };
129
+
130
+ const dispatchSelectedNotify = (context: NotifyContext, value: number | null, self: SelectableWidget): void => {
131
+ const { options, known, state } = context;
132
+
133
+ if (state.isApplying) {
134
+ return;
135
+ }
136
+
137
+ reportSelectedNotify(options, { known, onSelectionChanged: options.props.onSelectionChanged });
138
+ options.props.onNotifySelected?.(value, self);
117
139
  };
118
140
 
119
141
  const useDropDownSelection = (options: SelectionOptions): NotifySelectedHandler => {
120
142
  const { widget, collection } = options;
121
143
  const { selectedId } = options.props;
122
144
  const known = useRef<string | null>(null);
145
+ const [applyState] = useState<ApplyState>(newApplyState);
123
146
 
124
147
  const syncKnownSelection = useEffectEvent((position: number): void => {
125
148
  const effectiveId = collection.idAt(position);
@@ -140,13 +163,12 @@ const useDropDownSelection = (options: SelectionOptions): NotifySelectedHandler
140
163
  }
141
164
 
142
165
  const position = resolvePosition(widget, collection, selectedId);
143
- applySelectedPosition(widget, position);
166
+ applySelectedPosition(widget, position, applyState);
144
167
  syncKnownSelection(position);
145
- }, [widget, collection, selectedId]);
168
+ }, [widget, collection, selectedId, applyState]);
146
169
 
147
170
  return (value, self) => {
148
- reportSelectedNotify(options, { known, onSelectionChanged: options.props.onSelectionChanged });
149
- options.props.onNotifySelected?.(value, self);
171
+ dispatchSelectedNotify({ options, known, state: applyState }, value, self);
150
172
  };
151
173
  };
152
174
 
@@ -154,12 +176,11 @@ function DropDownBase(props: DropDownBaseProps & { component: ElementType }): Re
154
176
  const { component: Component, items, sections, renderListItem, renderHeader, ref } = props;
155
177
  const rest = omit(props, DROP_DOWN_PROPS);
156
178
  const [widget, refCallback] = useWidgetRef<SelectableWidget>(ref);
157
- const collection = useCollectionData({ items, sections });
179
+ const collection = useCollectionData({ items, sections, isFlat: true });
158
180
  const faceCells = useItemCells({ width: -1, height: -1 });
159
181
  const listCells = useItemCells({ width: -1, height: -1 });
160
- const headerCells = useHeaderCells({ width: -1, height: -1 });
182
+ const header = useSectionHeader(renderHeader, collection, { width: -1, height: -1 });
161
183
  const handleNotifySelected = useDropDownSelection({ widget, collection, props });
162
- const hasHeader = typeof renderHeader === "function";
163
184
 
164
185
  return (
165
186
  <>
@@ -170,15 +191,15 @@ function DropDownBase(props: DropDownBaseProps & { component: ElementType }): Re
170
191
  {...(renderListItem != null && {
171
192
  listFactory: <GtkSignalListItemFactory {...listCells.handlers} />,
172
193
  })}
173
- {...(hasHeader && { headerFactory: <GtkSignalListItemFactory {...headerCells.handlers} /> })}
194
+ {...header.factoryProps}
174
195
  {...rest}
175
196
  onNotifySelected={handleNotifySelected}
176
197
  />
177
- <ItemPortals store={faceCells} render={faceRenderer(props)} collection={collection} />
198
+ <ItemPortals registry={faceCells} render={faceRenderer(props)} collection={collection} />
178
199
  {renderListItem != null && (
179
- <ItemPortals store={listCells} render={listRenderer(props)} collection={collection} />
200
+ <ItemPortals registry={listCells} render={renderListItem} collection={collection} />
180
201
  )}
181
- {hasHeader && <HeaderPortals store={headerCells} render={renderHeader} collection={collection} />}
202
+ {header.portals}
182
203
  </>
183
204
  );
184
205
  }
@@ -1,8 +1,14 @@
1
1
  import type * as Gtk from "@gtkx/gi/gtk";
2
- import type { RefObject } from "react";
3
- import { useEffectEvent, useLayoutEffect, useRef, useState } from "react";
2
+ import { useState } from "react";
4
3
  import type { Collection } from "./collection.js";
5
- import { eachRow } from "./collection.js";
4
+ import type { TreeExpansion } from "./tree-expansion.js";
5
+ import type { VisibleOrder, WalkOptions } from "./tree-order.js";
6
+ import { isCollectionIdle } from "./collection.js";
7
+ import { useControlledSync } from "./controlled-sync.js";
8
+ import { joinParts } from "./keys.js";
9
+ import { trackPaths } from "./slots.js";
10
+ import { adoptOrder, markExpanded, orderFor } from "./tree-expansion.js";
11
+ import { walkVisible } from "./tree-order.js";
6
12
 
7
13
  type ExpansionOptions = {
8
14
  collection: Collection;
@@ -10,113 +16,144 @@ type ExpansionOptions = {
10
16
  onExpandedChange?: ((ids: string[]) => void) | null | undefined;
11
17
  };
12
18
 
19
+ type ItemsChange = {
20
+ position: number;
21
+ removed: number;
22
+ added: number;
23
+ };
24
+
25
+ type ItemsChangeHandler = (position: number, removed: number, added: number) => void;
13
26
  type LastExpansion = { key: string };
14
27
 
28
+ type ExpansionContext = {
29
+ collection: Collection;
30
+ last: LastExpansion;
31
+ onExpandedChange?: ((ids: string[]) => void) | null | undefined;
32
+ };
33
+
15
34
  function newLastExpansion(): LastExpansion {
16
35
  return { key: "" };
17
36
  }
18
37
 
19
- function collectExpanded(row: Gtk.TreeListRow, id: string | null, ids: string[]): void {
20
- if (id !== null && row.getExpanded()) {
21
- ids.push(id);
38
+ function wantedSet(expansion: TreeExpansion, expandedIds: string[]): Set<string> {
39
+ const wanted: Set<string> = new Set();
40
+
41
+ for (const id of expandedIds) {
42
+ for (const path of expansion.index.expandablePathsFor(id)) {
43
+ wanted.add(path);
44
+ }
22
45
  }
46
+
47
+ return wanted;
23
48
  }
24
49
 
25
- function expandRow(row: Gtk.TreeListRow, id: string | null, wanted: Set<string>): void {
26
- const isDesired = id !== null && wanted.has(id);
50
+ function hasSameExpansion(expanded: Set<string>, wanted: Set<string>): boolean {
51
+ return expanded.size === wanted.size && wanted.isSubsetOf(expanded);
52
+ }
27
53
 
28
- if (row.isExpandable() && row.getExpanded() !== isDesired) {
29
- row.setExpanded(isDesired);
30
- }
54
+ function toggleOptions(expansion: TreeExpansion, tree: Gtk.TreeListModel, wanted: Set<string>): WalkOptions {
55
+ return {
56
+ index: expansion.index,
57
+ slots: trackPaths(wanted),
58
+ marks: trackPaths(wanted.symmetricDifference(expansion.expanded)),
59
+ visit: (row) => {
60
+ if (row.isMarked) {
61
+ tree.getRow(row.position)?.setExpanded(row.isOpen);
62
+ }
63
+ },
64
+ };
31
65
  }
32
66
 
33
- function applyExpansion(tree: Gtk.TreeListModel, expandedIds: string[]): void {
34
- const wanted = new Set(expandedIds);
67
+ function walkApplying(expansion: TreeExpansion, options: WalkOptions): VisibleOrder {
68
+ expansion.isApplying = true;
35
69
 
36
- eachRow(tree, (row, id) => {
37
- expandRow(row, id, wanted);
38
- });
70
+ try {
71
+ return walkVisible(options);
72
+ } finally {
73
+ expansion.isApplying = false;
74
+ }
39
75
  }
40
76
 
41
- function reportExpansion(
42
- collection: Collection,
43
- last: LastExpansion,
44
- onExpandedChange: ((ids: string[]) => void) | null | undefined,
45
- ): void {
46
- const tree = collection.treeModel();
77
+ function applyWanted(expansion: TreeExpansion, tree: Gtk.TreeListModel, expandedIds: string[]): void {
78
+ const wanted = wantedSet(expansion, expandedIds);
47
79
 
48
- if (tree === null) {
80
+ if (hasSameExpansion(expansion.expanded, wanted)) {
49
81
  return;
50
82
  }
51
83
 
52
- const ids: string[] = [];
53
-
54
- eachRow(tree, (row, id) => {
55
- collectExpanded(row, id, ids);
56
- });
84
+ adoptOrder(expansion, walkApplying(expansion, toggleOptions(expansion, tree, wanted)));
85
+ }
57
86
 
58
- const key = ids.join(" ");
87
+ function reportExpansion(context: ExpansionContext): void {
88
+ const { expandedIds, expandedPaths } = orderFor(context.collection.expansion);
89
+ const key = joinParts(expandedPaths);
59
90
 
60
- if (last.key === key) {
91
+ if (context.last.key === key) {
61
92
  return;
62
93
  }
63
94
 
64
- last.key = key;
65
- onExpandedChange?.(ids);
95
+ context.last.key = key;
96
+ context.onExpandedChange?.([...expandedIds]);
66
97
  }
67
98
 
68
- function reportWhenIdle(
69
- collection: Collection,
70
- last: LastExpansion,
71
- expanding: RefObject<boolean>,
72
- onExpandedChange: ((ids: string[]) => void) | null | undefined,
73
- ): void {
74
- if (expanding.current) {
99
+ function applyControlledExpansion(context: ExpansionContext, expandedIds: string[] | null | undefined): void {
100
+ const tree = context.collection.treeModel();
101
+
102
+ if (tree === null) {
75
103
  return;
76
104
  }
77
105
 
78
- reportExpansion(collection, last, onExpandedChange);
106
+ applyWanted(context.collection.expansion, tree, expandedIds ?? []);
107
+ reportExpansion(context);
79
108
  }
80
109
 
81
- function runControlledExpansion(
82
- collection: Collection,
83
- expandedIds: string[] | null | undefined,
84
- expanding: RefObject<boolean>,
85
- report: () => void,
86
- ): void {
87
- const tree = collection.treeModel();
110
+ function isExpansionIdle(collection: Collection): boolean {
111
+ return collection.treeModel() !== null && isCollectionIdle(collection);
112
+ }
88
113
 
89
- if (tree === null || expandedIds == null) {
90
- return;
114
+ function didRowDrift(collection: Collection, change: ItemsChange): boolean {
115
+ const path = collection.pathAt(change.position - 1);
116
+ const isExpanded = change.removed === 0 && change.added > 0;
117
+ const isCollapsed = change.added === 0 && change.removed > 0;
118
+
119
+ if (path === null || isExpanded === isCollapsed) {
120
+ return false;
91
121
  }
92
122
 
93
- expanding.current = true;
123
+ markExpanded(collection.expansion, path, isExpanded);
94
124
 
95
- try {
96
- applyExpansion(tree, expandedIds);
97
- } finally {
98
- expanding.current = false;
125
+ return true;
126
+ }
127
+
128
+ function observeExpansion(context: ExpansionContext, change: ItemsChange, onDrift: () => void): void {
129
+ if (!isExpansionIdle(context.collection)) {
130
+ return;
99
131
  }
100
132
 
101
- report();
133
+ const didDrift = didRowDrift(context.collection, change);
134
+ reportExpansion(context);
135
+
136
+ if (didDrift) {
137
+ onDrift();
138
+ }
102
139
  }
103
140
 
104
- function useExpansion(options: ExpansionOptions): () => void {
141
+ function useExpansion(options: ExpansionOptions): ItemsChangeHandler {
105
142
  const { collection, expandedIds, onExpandedChange } = options;
106
143
  const [last] = useState<LastExpansion>(newLastExpansion);
107
- const expanding = useRef(false);
108
-
109
- const report = useEffectEvent((): void => {
110
- reportWhenIdle(collection, last, expanding, onExpandedChange);
144
+ const context: ExpansionContext = { collection, last, onExpandedChange };
145
+
146
+ const markDrift = useControlledSync({
147
+ ids: expandedIds,
148
+ collection,
149
+ apply: (ids) => {
150
+ applyControlledExpansion(context, ids);
151
+ },
111
152
  });
112
153
 
113
- useLayoutEffect(() => {
114
- runControlledExpansion(collection, expandedIds, expanding, report);
115
- }, [collection, expandedIds]);
116
-
117
- return () => {
118
- reportWhenIdle(collection, last, expanding, onExpandedChange);
154
+ return (position, removed, added) => {
155
+ observeExpansion(context, { position, removed, added }, markDrift);
119
156
  };
120
157
  }
121
158
 
122
- export { useExpansion, type ExpansionOptions };
159
+ export { useExpansion, type ItemsChangeHandler };
@@ -0,0 +1,18 @@
1
+ const LENGTH_SEPARATOR = "\u{1}";
2
+
3
+ const encodePart = (value: string): string => `${String(value.length)}${LENGTH_SEPARATOR}${value}`;
4
+ const joinParts = (values: string[]): string => values.map((value) => encodePart(value)).join("");
5
+
6
+ const decodePartAt = (value: string, offset: number): string | null => {
7
+ const separator = value.indexOf(LENGTH_SEPARATOR, offset);
8
+
9
+ if (separator === -1) {
10
+ return null;
11
+ }
12
+
13
+ const length = Number(value.slice(offset, separator));
14
+
15
+ return value.slice(separator + 1, separator + 1 + length);
16
+ };
17
+
18
+ export { decodePartAt, encodePart, joinParts };
@@ -2,15 +2,19 @@ import type * as Gio from "@gtkx/gi/gio";
2
2
  import type { ReactElement } from "react";
3
3
  import * as Gtk from "@gtkx/gi/gtk";
4
4
  import { GtkMultiSelection, GtkNoSelection, GtkSingleSelection } from "@gtkx/jsx/gtk";
5
- import { useEffectEvent, useLayoutEffect, useState } from "react";
5
+ import { useState } from "react";
6
6
  import type { Collection } from "./collection.js";
7
+ import type { ItemsChangeHandler } from "./expansion.js";
8
+ import { isCollectionIdle } from "./collection.js";
9
+ import { useControlledSync } from "./controlled-sync.js";
10
+ import { joinParts } from "./keys.js";
7
11
 
8
12
  type SelectionOptions = {
9
13
  collection: Collection;
10
14
  selectedIds?: string[] | null | undefined;
11
15
  onSelectionChanged?: ((ids: string[]) => void) | null | undefined;
12
16
  selectionMode?: Gtk.SelectionMode | null | undefined;
13
- onItemsChanged: () => void;
17
+ onItemsChanged: ItemsChangeHandler;
14
18
  };
15
19
 
16
20
  type LastSelection = {
@@ -18,29 +22,60 @@ type LastSelection = {
18
22
  key: string | null;
19
23
  };
20
24
 
25
+ type SelectionContext = {
26
+ selection: Gtk.SelectionModel | null;
27
+ collection: Collection;
28
+ last: LastSelection;
29
+ onSelectionChanged?: ((ids: string[]) => void) | null | undefined;
30
+ };
31
+
21
32
  type SelectionElementProps = {
22
33
  ref: (value: Gtk.SelectionModel | null) => void;
23
34
  model: Gio.ListModel;
24
35
  onSelectionChanged: () => void;
25
- onItemsChanged: () => void;
36
+ onItemsChanged: ItemsChangeHandler;
26
37
  };
27
38
 
28
- function getSelectedIds(selection: Gtk.SelectionModel, collection: Collection): string[] {
39
+ function selectedPositions(selection: Gtk.SelectionModel): number[] {
29
40
  const bitset = selection.getSelection();
30
41
  const size = Number(bitset.getSize());
31
- const ids: string[] = [];
42
+ const positions: number[] = [];
32
43
 
33
44
  for (let index = 0; index < size; index++) {
34
- pushId(collection.idAt(bitset.getNth(index)), ids);
45
+ positions.push(bitset.getNth(index));
46
+ }
47
+
48
+ return positions;
49
+ }
50
+
51
+ function idsAt(collection: Collection, positions: number[]): string[] {
52
+ const ids: string[] = [];
53
+
54
+ for (const position of positions) {
55
+ const id = collection.idAt(position);
56
+
57
+ if (id !== null) {
58
+ ids.push(id);
59
+ }
35
60
  }
36
61
 
37
62
  return ids;
38
63
  }
39
64
 
40
- function pushId(id: string | null, ids: string[]): void {
41
- if (id !== null) {
42
- ids.push(id);
65
+ function plannedPositions(context: SelectionContext, ids: string[]): number[] | null {
66
+ const { selection, collection } = context;
67
+
68
+ if (selection === null || selection instanceof Gtk.NoSelection) {
69
+ return null;
43
70
  }
71
+
72
+ const positions = collection.positionsFor(ids);
73
+
74
+ if (ids.length > 0 && positions.length === 0) {
75
+ return null;
76
+ }
77
+
78
+ return selection instanceof Gtk.SingleSelection ? positions.slice(0, 1) : positions;
44
79
  }
45
80
 
46
81
  function applySingleSelection(selection: Gtk.SingleSelection, positions: number[]): void {
@@ -65,17 +100,7 @@ function applyMultiSelection(selection: Gtk.SelectionModel, positions: number[])
65
100
  selection.setSelection(selected, Gtk.Bitset.newRange(0, selection.getNItems()));
66
101
  }
67
102
 
68
- function applySelection(selection: Gtk.SelectionModel, collection: Collection, ids: string[]): void {
69
- if (selection instanceof Gtk.NoSelection) {
70
- return;
71
- }
72
-
73
- const positions = collection.positionsFor(ids);
74
-
75
- if (ids.length > 0 && positions.length === 0) {
76
- return;
77
- }
78
-
103
+ function applySelection(selection: Gtk.SelectionModel, positions: number[]): void {
79
104
  if (selection instanceof Gtk.SingleSelection) {
80
105
  applySingleSelection(selection, positions);
81
106
 
@@ -85,26 +110,20 @@ function applySelection(selection: Gtk.SelectionModel, collection: Collection, i
85
110
  applyMultiSelection(selection, positions);
86
111
  }
87
112
 
88
- function reportSelection(
89
- selection: Gtk.SelectionModel | null,
90
- collection: Collection,
91
- last: LastSelection,
92
- onSelectionChanged: ((ids: string[]) => void) | null | undefined,
93
- ): void {
94
- if (selection === null) {
95
- return;
96
- }
97
-
98
- const ids = getSelectedIds(selection, collection);
99
- const key = ids.join(" ");
113
+ function reportSelection(context: SelectionContext, ids: string[]): void {
114
+ const key = joinParts(ids);
100
115
 
101
- if (last.selection === selection && last.key === key) {
116
+ if (context.last.selection === context.selection && context.last.key === key) {
102
117
  return;
103
118
  }
104
119
 
105
- last.selection = selection;
106
- last.key = key;
107
- onSelectionChanged?.(ids);
120
+ context.last.selection = context.selection;
121
+ context.last.key = key;
122
+ context.onSelectionChanged?.(ids);
123
+ }
124
+
125
+ function hasSelectionDrifted(actual: number[], planned: number[]): boolean {
126
+ return actual.length !== planned.length || planned.some((position, index) => actual[index] !== position);
108
127
  }
109
128
 
110
129
  function selectionElement(mode: Gtk.SelectionMode | null | undefined, props: SelectionElementProps): ReactElement {
@@ -119,16 +138,45 @@ function selectionElement(mode: Gtk.SelectionMode | null | undefined, props: Sel
119
138
  return <GtkSingleSelection {...props} autoselect={false} canUnselect />;
120
139
  }
121
140
 
122
- function syncSelection(
123
- selection: Gtk.SelectionModel | null,
124
- collection: Collection,
141
+ function applyControlledSelection(context: SelectionContext, selectedIds: string[] | null | undefined): void {
142
+ const { selection, collection } = context;
143
+
144
+ if (selection === null) {
145
+ return;
146
+ }
147
+
148
+ const planned = plannedPositions(context, selectedIds ?? []);
149
+
150
+ if (planned !== null) {
151
+ applySelection(selection, planned);
152
+ }
153
+
154
+ reportSelection(context, idsAt(collection, selectedPositions(selection)));
155
+ }
156
+
157
+ function observeSelection(
158
+ context: SelectionContext,
125
159
  selectedIds: string[] | null | undefined,
160
+ onDrift: () => void,
126
161
  ): void {
127
- if (selection === null || selectedIds == null) {
162
+ const { selection, collection } = context;
163
+
164
+ if (selection === null) {
165
+ return;
166
+ }
167
+
168
+ const positions = selectedPositions(selection);
169
+ reportSelection(context, idsAt(collection, positions));
170
+
171
+ if (!isCollectionIdle(collection)) {
128
172
  return;
129
173
  }
130
174
 
131
- applySelection(selection, collection, selectedIds);
175
+ const planned = plannedPositions(context, selectedIds ?? []);
176
+
177
+ if (planned !== null && hasSelectionDrifted(positions, planned)) {
178
+ onDrift();
179
+ }
132
180
  }
133
181
 
134
182
  function newLastSelection(): LastSelection {
@@ -139,24 +187,25 @@ function useSelection(options: SelectionOptions): ReactElement {
139
187
  const { collection, selectedIds, onSelectionChanged, selectionMode } = options;
140
188
  const [selection, setSelection] = useState<Gtk.SelectionModel | null>(null);
141
189
  const [last] = useState<LastSelection>(newLastSelection);
142
-
143
- const report = useEffectEvent((): void => {
144
- reportSelection(selection, collection, last, onSelectionChanged);
190
+ const context: SelectionContext = { selection, collection, last, onSelectionChanged };
191
+
192
+ const markDrift = useControlledSync({
193
+ ids: selectedIds,
194
+ collection,
195
+ widget: selection,
196
+ apply: (ids) => {
197
+ applyControlledSelection(context, ids);
198
+ },
145
199
  });
146
200
 
147
- useLayoutEffect(() => {
148
- syncSelection(selection, collection, selectedIds);
149
- report();
150
- }, [selection, collection, selectedIds]);
151
-
152
201
  return selectionElement(selectionMode, {
153
202
  ref: setSelection,
154
203
  model: collection.model,
155
204
  onSelectionChanged: () => {
156
- reportSelection(selection, collection, last, onSelectionChanged);
205
+ observeSelection(context, selectedIds, markDrift);
157
206
  },
158
207
  onItemsChanged: options.onItemsChanged,
159
208
  });
160
209
  }
161
210
 
162
- export { useSelection, type SelectionOptions };
211
+ export { useSelection };