@gtkx/components 0.21.0 → 1.0.0-rc.2

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 (192) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +173 -0
  3. package/dist/adw/index.d.ts +3 -0
  4. package/dist/adw/index.d.ts.map +1 -0
  5. package/dist/adw/index.js +2 -0
  6. package/dist/adw/index.js.map +1 -0
  7. package/dist/adw/toast.d.ts +10 -0
  8. package/dist/adw/toast.d.ts.map +1 -0
  9. package/dist/adw/toast.js +51 -0
  10. package/dist/adw/toast.js.map +1 -0
  11. package/dist/adw/types.d.ts +28 -0
  12. package/dist/adw/types.d.ts.map +1 -0
  13. package/dist/adw/types.js +2 -0
  14. package/dist/adw/types.js.map +1 -0
  15. package/dist/column-view.d.ts +6 -14
  16. package/dist/column-view.d.ts.map +1 -1
  17. package/dist/column-view.js +98 -100
  18. package/dist/column-view.js.map +1 -1
  19. package/dist/drop-down.d.ts +8 -37
  20. package/dist/drop-down.d.ts.map +1 -1
  21. package/dist/drop-down.js +109 -118
  22. package/dist/drop-down.js.map +1 -1
  23. package/dist/grid-view.d.ts +4 -11
  24. package/dist/grid-view.d.ts.map +1 -1
  25. package/dist/grid-view.js +25 -19
  26. package/dist/grid-view.js.map +1 -1
  27. package/dist/index.d.ts +6 -18
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -14
  30. package/dist/index.js.map +1 -1
  31. package/dist/internal/cells.d.ts +50 -0
  32. package/dist/internal/cells.d.ts.map +1 -0
  33. package/dist/internal/cells.js +185 -0
  34. package/dist/internal/cells.js.map +1 -0
  35. package/dist/internal/collection-index.d.ts +21 -0
  36. package/dist/internal/collection-index.d.ts.map +1 -0
  37. package/dist/internal/collection-index.js +106 -0
  38. package/dist/internal/collection-index.js.map +1 -0
  39. package/dist/internal/collection-model.d.ts +12 -0
  40. package/dist/internal/collection-model.d.ts.map +1 -0
  41. package/dist/internal/collection-model.js +164 -0
  42. package/dist/internal/collection-model.js.map +1 -0
  43. package/dist/internal/collection.d.ts +18 -0
  44. package/dist/internal/collection.d.ts.map +1 -0
  45. package/dist/internal/collection.js +64 -0
  46. package/dist/internal/collection.js.map +1 -0
  47. package/dist/internal/expansion.d.ts +9 -0
  48. package/dist/internal/expansion.d.ts.map +1 -0
  49. package/dist/internal/expansion.js +75 -0
  50. package/dist/internal/expansion.js.map +1 -0
  51. package/dist/internal/selection.d.ts +12 -0
  52. package/dist/internal/selection.d.ts.map +1 -0
  53. package/dist/internal/selection.js +99 -0
  54. package/dist/internal/selection.js.map +1 -0
  55. package/dist/internal/use-collection.d.ts +18 -0
  56. package/dist/internal/use-collection.d.ts.map +1 -0
  57. package/dist/internal/use-collection.js +42 -0
  58. package/dist/internal/use-collection.js.map +1 -0
  59. package/dist/internal/use-widget-ref.d.ts +4 -0
  60. package/dist/internal/use-widget-ref.d.ts.map +1 -0
  61. package/dist/internal/use-widget-ref.js +8 -0
  62. package/dist/internal/use-widget-ref.js.map +1 -0
  63. package/dist/list-view.d.ts +4 -11
  64. package/dist/list-view.d.ts.map +1 -1
  65. package/dist/list-view.js +32 -28
  66. package/dist/list-view.js.map +1 -1
  67. package/dist/size-group.d.ts +12 -0
  68. package/dist/size-group.d.ts.map +1 -0
  69. package/dist/size-group.js +41 -0
  70. package/dist/size-group.js.map +1 -0
  71. package/dist/types.d.ts +104 -161
  72. package/dist/types.d.ts.map +1 -1
  73. package/dist/types.js.map +1 -1
  74. package/package.json +62 -52
  75. package/src/adw/index.ts +2 -0
  76. package/src/adw/toast.tsx +74 -0
  77. package/src/adw/types.ts +32 -0
  78. package/src/column-view.tsx +184 -184
  79. package/src/drop-down.tsx +173 -227
  80. package/src/grid-view.tsx +29 -65
  81. package/src/index.ts +16 -25
  82. package/src/internal/cells.tsx +345 -0
  83. package/src/internal/collection-index.ts +168 -0
  84. package/src/internal/collection-model.ts +246 -0
  85. package/src/internal/collection.ts +104 -0
  86. package/src/internal/expansion.ts +123 -0
  87. package/src/internal/selection.tsx +159 -0
  88. package/src/internal/use-collection.tsx +70 -0
  89. package/src/internal/use-widget-ref.ts +11 -0
  90. package/src/list-view.tsx +45 -78
  91. package/src/size-group.tsx +77 -0
  92. package/src/types.ts +171 -191
  93. package/dist/collection-view.d.ts +0 -30
  94. package/dist/collection-view.d.ts.map +0 -1
  95. package/dist/collection-view.js +0 -52
  96. package/dist/collection-view.js.map +0 -1
  97. package/dist/column-view-column.d.ts +0 -17
  98. package/dist/column-view-column.d.ts.map +0 -1
  99. package/dist/column-view-column.js +0 -51
  100. package/dist/column-view-column.js.map +0 -1
  101. package/dist/constraint-layout-apply.d.ts +0 -6
  102. package/dist/constraint-layout-apply.d.ts.map +0 -1
  103. package/dist/constraint-layout-apply.js +0 -107
  104. package/dist/constraint-layout-apply.js.map +0 -1
  105. package/dist/constraint-layout.d.ts +0 -13
  106. package/dist/constraint-layout.d.ts.map +0 -1
  107. package/dist/constraint-layout.js +0 -85
  108. package/dist/constraint-layout.js.map +0 -1
  109. package/dist/contexts/column-view-context.d.ts +0 -17
  110. package/dist/contexts/column-view-context.d.ts.map +0 -1
  111. package/dist/contexts/column-view-context.js +0 -10
  112. package/dist/contexts/column-view-context.js.map +0 -1
  113. package/dist/hooks/use-drop-down-selection.d.ts +0 -15
  114. package/dist/hooks/use-drop-down-selection.d.ts.map +0 -1
  115. package/dist/hooks/use-drop-down-selection.js +0 -35
  116. package/dist/hooks/use-drop-down-selection.js.map +0 -1
  117. package/dist/hooks/use-header-menu.d.ts +0 -4
  118. package/dist/hooks/use-header-menu.d.ts.map +0 -1
  119. package/dist/hooks/use-header-menu.js +0 -32
  120. package/dist/hooks/use-header-menu.js.map +0 -1
  121. package/dist/hooks/use-list-model.d.ts +0 -20
  122. package/dist/hooks/use-list-model.d.ts.map +0 -1
  123. package/dist/hooks/use-list-model.js +0 -70
  124. package/dist/hooks/use-list-model.js.map +0 -1
  125. package/dist/hooks/use-model-installation.d.ts +0 -9
  126. package/dist/hooks/use-model-installation.d.ts.map +0 -1
  127. package/dist/hooks/use-model-installation.js +0 -20
  128. package/dist/hooks/use-model-installation.js.map +0 -1
  129. package/dist/hooks/use-realized-slots.d.ts +0 -20
  130. package/dist/hooks/use-realized-slots.d.ts.map +0 -1
  131. package/dist/hooks/use-realized-slots.js +0 -66
  132. package/dist/hooks/use-realized-slots.js.map +0 -1
  133. package/dist/hooks/use-selection-model.d.ts +0 -12
  134. package/dist/hooks/use-selection-model.d.ts.map +0 -1
  135. package/dist/hooks/use-selection-model.js +0 -111
  136. package/dist/hooks/use-selection-model.js.map +0 -1
  137. package/dist/hooks/use-sort-handler.d.ts +0 -12
  138. package/dist/hooks/use-sort-handler.d.ts.map +0 -1
  139. package/dist/hooks/use-sort-handler.js +0 -87
  140. package/dist/hooks/use-sort-handler.js.map +0 -1
  141. package/dist/list-portal-host.d.ts +0 -11
  142. package/dist/list-portal-host.d.ts.map +0 -1
  143. package/dist/list-portal-host.js +0 -8
  144. package/dist/list-portal-host.js.map +0 -1
  145. package/dist/list-slot.d.ts +0 -14
  146. package/dist/list-slot.d.ts.map +0 -1
  147. package/dist/list-slot.js +0 -29
  148. package/dist/list-slot.js.map +0 -1
  149. package/dist/menu-items.d.ts +0 -5
  150. package/dist/menu-items.d.ts.map +0 -1
  151. package/dist/menu-items.js +0 -48
  152. package/dist/menu-items.js.map +0 -1
  153. package/dist/menu.d.ts +0 -18
  154. package/dist/menu.d.ts.map +0 -1
  155. package/dist/menu.js +0 -26
  156. package/dist/menu.js.map +0 -1
  157. package/dist/utils/item-resolver.d.ts +0 -28
  158. package/dist/utils/item-resolver.d.ts.map +0 -1
  159. package/dist/utils/item-resolver.js +0 -111
  160. package/dist/utils/item-resolver.js.map +0 -1
  161. package/dist/utils/list-item-flatten.d.ts +0 -32
  162. package/dist/utils/list-item-flatten.d.ts.map +0 -1
  163. package/dist/utils/list-item-flatten.js +0 -88
  164. package/dist/utils/list-item-flatten.js.map +0 -1
  165. package/dist/utils/position-only-model.d.ts +0 -10
  166. package/dist/utils/position-only-model.d.ts.map +0 -1
  167. package/dist/utils/position-only-model.js +0 -83
  168. package/dist/utils/position-only-model.js.map +0 -1
  169. package/dist/utils/realized-slot-store.d.ts +0 -30
  170. package/dist/utils/realized-slot-store.d.ts.map +0 -1
  171. package/dist/utils/realized-slot-store.js +0 -103
  172. package/dist/utils/realized-slot-store.js.map +0 -1
  173. package/src/collection-view.tsx +0 -125
  174. package/src/column-view-column.tsx +0 -73
  175. package/src/constraint-layout-apply.ts +0 -121
  176. package/src/constraint-layout.tsx +0 -130
  177. package/src/contexts/column-view-context.ts +0 -28
  178. package/src/hooks/use-drop-down-selection.ts +0 -53
  179. package/src/hooks/use-header-menu.ts +0 -33
  180. package/src/hooks/use-list-model.ts +0 -145
  181. package/src/hooks/use-model-installation.ts +0 -25
  182. package/src/hooks/use-realized-slots.ts +0 -93
  183. package/src/hooks/use-selection-model.ts +0 -121
  184. package/src/hooks/use-sort-handler.ts +0 -102
  185. package/src/list-portal-host.tsx +0 -27
  186. package/src/list-slot.tsx +0 -56
  187. package/src/menu-items.ts +0 -48
  188. package/src/menu.tsx +0 -34
  189. package/src/utils/item-resolver.ts +0 -143
  190. package/src/utils/list-item-flatten.ts +0 -113
  191. package/src/utils/position-only-model.ts +0 -115
  192. package/src/utils/realized-slot-store.ts +0 -126
@@ -0,0 +1,246 @@
1
+ import * as Gio from "@gtkx/gi/gio";
2
+ import * as GObject from "@gtkx/gi/gobject";
3
+ import * as Gtk from "@gtkx/gi/gtk";
4
+ import type { CollectionIndex, Level } from "./collection-index.js";
5
+ import { childLevelKey, ROOT_LEVEL_KEY } from "./collection-index.js";
6
+
7
+ type LevelState = {
8
+ store: Gio.ListStore;
9
+ ids: string[];
10
+ canExpand: Map<string, boolean>;
11
+ };
12
+
13
+ type SpliceRange = {
14
+ start: number;
15
+ removed: number;
16
+ added: string[];
17
+ };
18
+
19
+ type ModelState = {
20
+ root: Gio.ListStore;
21
+ rootModels: GObject.Object[];
22
+ model: Gtk.FlattenListModel;
23
+ objects: Map<string, Gtk.StringObject>;
24
+ levels: Map<string, LevelState>;
25
+ tree: Gtk.TreeListModel | null;
26
+ };
27
+
28
+ type CollectionModel = {
29
+ model: Gtk.FlattenListModel;
30
+ treeModel: () => Gtk.TreeListModel | null;
31
+ sync: (index: CollectionIndex) => void;
32
+ };
33
+
34
+ const OBJECT_CACHE_MAX = 1 << 20;
35
+
36
+ const newStore = (): Gio.ListStore => new Gio.ListStore({ itemType: GObject.TYPE_OBJECT });
37
+
38
+ function getId(value: GObject.Object | null): string | null {
39
+ const item = value instanceof Gtk.TreeListRow ? value.getItem() : value;
40
+
41
+ return item instanceof Gtk.StringObject ? item.getString() : null;
42
+ }
43
+
44
+ function objectFor(state: ModelState, id: string): Gtk.StringObject {
45
+ const existing = state.objects.get(id);
46
+
47
+ if (existing !== undefined) {
48
+ return existing;
49
+ }
50
+
51
+ const created = Gtk.StringObject.new(id);
52
+ state.objects.set(id, created);
53
+
54
+ return created;
55
+ }
56
+
57
+ function commonPrefix(previous: string[], next: string[], max: number): number {
58
+ let count = 0;
59
+
60
+ while (count < max && previous[count] === next[count]) {
61
+ count += 1;
62
+ }
63
+
64
+ return count;
65
+ }
66
+
67
+ function commonSuffix(previous: string[], next: string[], max: number): number {
68
+ let count = 0;
69
+
70
+ while (count < max && previous[previous.length - 1 - count] === next[next.length - 1 - count]) {
71
+ count += 1;
72
+ }
73
+
74
+ return count;
75
+ }
76
+
77
+ function spliceRange(previous: string[], next: string[]): SpliceRange | null {
78
+ const shared = Math.min(previous.length, next.length);
79
+ const start = commonPrefix(previous, next, shared);
80
+
81
+ if (start === previous.length && start === next.length) {
82
+ return null;
83
+ }
84
+
85
+ const tail = commonSuffix(previous, next, shared - start);
86
+
87
+ return { start, removed: previous.length - start - tail, added: next.slice(start, next.length - tail) };
88
+ }
89
+
90
+ function levelFor(state: ModelState, key: string): LevelState {
91
+ const existing = state.levels.get(key);
92
+
93
+ if (existing !== undefined) {
94
+ return existing;
95
+ }
96
+
97
+ const created: LevelState = { store: newStore(), ids: [], canExpand: new Map() };
98
+ state.levels.set(key, created);
99
+
100
+ return created;
101
+ }
102
+
103
+ function refreshExpandable(current: LevelState, level: Level): void {
104
+ const next: Map<string, boolean> = new Map();
105
+
106
+ for (const [index, id] of level.ids.entries()) {
107
+ const isWanted = level.isExpandable[index] ?? false;
108
+ const previous = current.canExpand.get(id);
109
+ next.set(id, isWanted);
110
+
111
+ if (previous !== undefined && previous !== isWanted) {
112
+ current.store.itemsChanged(index, 1, 1);
113
+ }
114
+ }
115
+
116
+ current.canExpand = next;
117
+ }
118
+
119
+ function didSpliceLevel(state: ModelState, level: Level): boolean {
120
+ const current = levelFor(state, level.key);
121
+ const range = spliceRange(current.ids, level.ids);
122
+
123
+ if (range === null) {
124
+ return false;
125
+ }
126
+
127
+ current.store.splice(range.start, range.removed, range.added.map((id) => objectFor(state, id)));
128
+ current.ids = [...level.ids];
129
+
130
+ return true;
131
+ }
132
+
133
+ function dropUnvisited(state: ModelState, visited: Set<string>): void {
134
+ for (const key of state.levels.keys()) {
135
+ if (!visited.has(key)) {
136
+ state.levels.delete(key);
137
+ }
138
+ }
139
+ }
140
+
141
+ // Items keep their identity by id, so a collection that shrinks keeps its objects cached: filtering a long
142
+ // list down and back up reuses them instead of rebuilding one GObject per row. Incremental filters shrink and
143
+ // grow the list many times per keystroke, so the cache is only swept once it passes an absolute bound.
144
+ function pruneObjects(state: ModelState, index: CollectionIndex): void {
145
+ if (state.objects.size <= OBJECT_CACHE_MAX) {
146
+ return;
147
+ }
148
+
149
+ for (const id of state.objects.keys()) {
150
+ if (!index.has(id)) {
151
+ state.objects.delete(id);
152
+ }
153
+ }
154
+ }
155
+
156
+ function childStoreFor(state: ModelState, object: GObject.Object): Gio.ListStore | null {
157
+ const id = getId(object);
158
+
159
+ if (id === null) {
160
+ return null;
161
+ }
162
+
163
+ return state.levels.get(childLevelKey(id))?.store ?? null;
164
+ }
165
+
166
+ function ensureTree(state: ModelState): Gtk.TreeListModel {
167
+ state.tree ??= Gtk.TreeListModel.new(levelFor(state, ROOT_LEVEL_KEY).store, false, false, (object) =>
168
+ childStoreFor(state, object));
169
+
170
+ return state.tree;
171
+ }
172
+
173
+ function desiredRootModels(state: ModelState, index: CollectionIndex): GObject.Object[] {
174
+ if (index.isTree) {
175
+ return [ensureTree(state)];
176
+ }
177
+
178
+ return index.groups.map((level) => levelFor(state, level.key).store);
179
+ }
180
+
181
+ function hasSameModels(previous: GObject.Object[], next: GObject.Object[]): boolean {
182
+ return previous.length === next.length && next.every((model, index) => previous[index] === model);
183
+ }
184
+
185
+ function syncRoot(state: ModelState, index: CollectionIndex): void {
186
+ const next = desiredRootModels(state, index);
187
+
188
+ if (hasSameModels(state.rootModels, next)) {
189
+ return;
190
+ }
191
+
192
+ state.root.splice(0, state.rootModels.length, next);
193
+ state.rootModels = next;
194
+ }
195
+
196
+ function syncModel(state: ModelState, index: CollectionIndex): void {
197
+ const levels = [...index.groups, ...index.children.values()];
198
+ const visited: Set<string> = new Set();
199
+ let didChange = false;
200
+
201
+ for (const level of levels) {
202
+ didChange = didSpliceLevel(state, level) || didChange;
203
+ visited.add(level.key);
204
+ }
205
+
206
+ if (didChange || visited.size !== state.levels.size) {
207
+ dropUnvisited(state, visited);
208
+ pruneObjects(state, index);
209
+ }
210
+
211
+ syncRoot(state, index);
212
+ refreshExpandableLevels(state, index, levels);
213
+ }
214
+
215
+ function refreshExpandableLevels(state: ModelState, index: CollectionIndex, levels: Level[]): void {
216
+ if (!index.isTree) {
217
+ return;
218
+ }
219
+
220
+ for (const level of levels) {
221
+ refreshExpandable(levelFor(state, level.key), level);
222
+ }
223
+ }
224
+
225
+ function createCollectionModel(): CollectionModel {
226
+ const root = newStore();
227
+
228
+ const state: ModelState = {
229
+ root,
230
+ rootModels: [],
231
+ model: Gtk.FlattenListModel.new(root),
232
+ objects: new Map(),
233
+ levels: new Map(),
234
+ tree: null,
235
+ };
236
+
237
+ return {
238
+ model: state.model,
239
+ treeModel: () => state.tree,
240
+ sync: (index) => {
241
+ syncModel(state, index);
242
+ },
243
+ };
244
+ }
245
+
246
+ export { createCollectionModel, getId, type CollectionModel };
@@ -0,0 +1,104 @@
1
+ import type * as GObject from "@gtkx/gi/gobject";
2
+ import type * as Gtk from "@gtkx/gi/gtk";
3
+ import type { Item } from "../types.js";
4
+ import type { CollectionIndex } from "./collection-index.js";
5
+ import type { CollectionModel } from "./collection-model.js";
6
+ import { getId } from "./collection-model.js";
7
+
8
+ type Collection = {
9
+ isTree: boolean;
10
+ model: Gtk.FlattenListModel;
11
+ treeModel: () => Gtk.TreeListModel | null;
12
+ itemFor: (id: string) => Item | undefined;
13
+ sectionFor: (id: string) => unknown;
14
+ idAt: (position: number) => string | null;
15
+ positionFor: (id: string) => number;
16
+ positionsFor: (ids: string[]) => number[];
17
+ };
18
+
19
+ const ascending = (a: number, b: number): number => a - b;
20
+
21
+ function visitRow(
22
+ tree: Gtk.TreeListModel,
23
+ position: number,
24
+ visit: (row: Gtk.TreeListRow, id: string | null) => void,
25
+ ): void {
26
+ const row = tree.getRow(position);
27
+
28
+ if (row === null) {
29
+ return;
30
+ }
31
+
32
+ visit(row, getId(row.getItem()));
33
+ }
34
+
35
+ function eachRow(tree: Gtk.TreeListModel, visit: (row: Gtk.TreeListRow, id: string | null) => void): void {
36
+ for (let position = 0; position < tree.getNItems(); position++) {
37
+ visitRow(tree, position, visit);
38
+ }
39
+ }
40
+
41
+ function collectWanted(item: GObject.Object | null, position: number, wanted: Set<string>, out: number[]): void {
42
+ const id = getId(item);
43
+
44
+ if (id !== null && wanted.has(id)) {
45
+ out.push(position);
46
+ }
47
+ }
48
+
49
+ function scanPositions(tree: Gtk.TreeListModel, ids: string[]): number[] {
50
+ const wanted = new Set(ids);
51
+ const positions: number[] = [];
52
+ const count = tree.getNItems();
53
+
54
+ for (let position = 0; position < count; position++) {
55
+ collectWanted(tree.getItem(position), position, wanted, positions);
56
+ }
57
+
58
+ return positions;
59
+ }
60
+
61
+ function indexPositions(index: CollectionIndex, ids: string[]): number[] {
62
+ const positions: number[] = [];
63
+
64
+ for (const id of ids) {
65
+ const position = index.positionFor(id);
66
+
67
+ if (position >= 0) {
68
+ positions.push(position);
69
+ }
70
+ }
71
+
72
+ return positions.toSorted(ascending);
73
+ }
74
+
75
+ function positionsFor(gtk: CollectionModel, index: CollectionIndex, ids: string[]): number[] {
76
+ if (!index.isTree) {
77
+ return indexPositions(index, ids);
78
+ }
79
+
80
+ const tree = gtk.treeModel();
81
+
82
+ return tree === null ? [] : scanPositions(tree, ids);
83
+ }
84
+
85
+ function positionFor(gtk: CollectionModel, index: CollectionIndex, id: string): number {
86
+ const [first = -1] = positionsFor(gtk, index, [id]);
87
+
88
+ return first;
89
+ }
90
+
91
+ function createCollection(gtk: CollectionModel, index: CollectionIndex): Collection {
92
+ return {
93
+ isTree: index.isTree,
94
+ model: gtk.model,
95
+ treeModel: () => (index.isTree ? gtk.treeModel() : null),
96
+ itemFor: index.itemFor,
97
+ sectionFor: index.sectionFor,
98
+ idAt: (position) => getId(gtk.model.getItem(position)),
99
+ positionFor: (id) => positionFor(gtk, index, id),
100
+ positionsFor: (ids) => positionsFor(gtk, index, ids),
101
+ };
102
+ }
103
+
104
+ export { createCollection, eachRow, type Collection };
@@ -0,0 +1,123 @@
1
+ import type * as Gtk from "@gtkx/gi/gtk";
2
+ import type { RefObject } from "react";
3
+ import { useSignal } from "@gtkx/react";
4
+ import { useEffectEvent, useLayoutEffect, useRef, useState } from "react";
5
+ import type { Collection } from "./collection.js";
6
+ import { eachRow } from "./collection.js";
7
+
8
+ type ExpansionOptions = {
9
+ collection: Collection;
10
+ expandedIds?: string[] | null | undefined;
11
+ onExpandedChange?: ((ids: string[]) => void) | null | undefined;
12
+ };
13
+
14
+ type LastExpansion = { key: string };
15
+
16
+ function newLastExpansion(): LastExpansion {
17
+ return { key: "" };
18
+ }
19
+
20
+ function collectExpanded(row: Gtk.TreeListRow, id: string | null, ids: string[]): void {
21
+ if (id !== null && row.getExpanded()) {
22
+ ids.push(id);
23
+ }
24
+ }
25
+
26
+ function expandRow(row: Gtk.TreeListRow, id: string | null, wanted: Set<string>): void {
27
+ const isDesired = id !== null && wanted.has(id);
28
+
29
+ if (row.isExpandable() && row.getExpanded() !== isDesired) {
30
+ row.setExpanded(isDesired);
31
+ }
32
+ }
33
+
34
+ function applyExpansion(tree: Gtk.TreeListModel, expandedIds: string[]): void {
35
+ const wanted = new Set(expandedIds);
36
+
37
+ eachRow(tree, (row, id) => {
38
+ expandRow(row, id, wanted);
39
+ });
40
+ }
41
+
42
+ function reportExpansion(
43
+ collection: Collection,
44
+ last: LastExpansion,
45
+ onExpandedChange: ((ids: string[]) => void) | null | undefined,
46
+ ): void {
47
+ const tree = collection.treeModel();
48
+
49
+ if (tree === null) {
50
+ return;
51
+ }
52
+
53
+ const ids: string[] = [];
54
+
55
+ eachRow(tree, (row, id) => {
56
+ collectExpanded(row, id, ids);
57
+ });
58
+
59
+ const key = ids.join(" ");
60
+
61
+ if (last.key === key) {
62
+ return;
63
+ }
64
+
65
+ last.key = key;
66
+ onExpandedChange?.(ids);
67
+ }
68
+
69
+ function reportWhenIdle(
70
+ collection: Collection,
71
+ last: LastExpansion,
72
+ expanding: RefObject<boolean>,
73
+ onExpandedChange: ((ids: string[]) => void) | null | undefined,
74
+ ): void {
75
+ if (expanding.current) {
76
+ return;
77
+ }
78
+
79
+ reportExpansion(collection, last, onExpandedChange);
80
+ }
81
+
82
+ function runControlledExpansion(
83
+ collection: Collection,
84
+ expandedIds: string[] | null | undefined,
85
+ expanding: RefObject<boolean>,
86
+ report: () => void,
87
+ ): void {
88
+ const tree = collection.treeModel();
89
+
90
+ if (tree === null || expandedIds == null) {
91
+ return;
92
+ }
93
+
94
+ expanding.current = true;
95
+
96
+ try {
97
+ applyExpansion(tree, expandedIds);
98
+ } finally {
99
+ expanding.current = false;
100
+ }
101
+
102
+ report();
103
+ }
104
+
105
+ function useExpansion(options: ExpansionOptions): void {
106
+ const { collection, expandedIds, onExpandedChange } = options;
107
+ const [last] = useState<LastExpansion>(newLastExpansion);
108
+ const expanding = useRef(false);
109
+
110
+ const report = useEffectEvent((): void => {
111
+ reportWhenIdle(collection, last, expanding, onExpandedChange);
112
+ });
113
+
114
+ useSignal(collection.isTree ? collection.model : null, "items-changed", () => {
115
+ reportWhenIdle(collection, last, expanding, onExpandedChange);
116
+ });
117
+
118
+ useLayoutEffect(() => {
119
+ runControlledExpansion(collection, expandedIds, expanding, report);
120
+ }, [collection, expandedIds]);
121
+ }
122
+
123
+ export { useExpansion, type ExpansionOptions };
@@ -0,0 +1,159 @@
1
+ import type * as Gio from "@gtkx/gi/gio";
2
+ import type { ReactElement } from "react";
3
+ import * as Gtk from "@gtkx/gi/gtk";
4
+ import { GtkMultiSelection, GtkNoSelection, GtkSingleSelection } from "@gtkx/jsx/gtk";
5
+ import { useEffectEvent, useLayoutEffect, useState } from "react";
6
+ import type { Collection } from "./collection.js";
7
+
8
+ type SelectionOptions = {
9
+ collection: Collection;
10
+ selectedIds?: string[] | null | undefined;
11
+ onSelectionChanged?: ((ids: string[]) => void) | null | undefined;
12
+ selectionMode?: Gtk.SelectionMode | null | undefined;
13
+ };
14
+
15
+ type LastSelection = {
16
+ selection: Gtk.SelectionModel | null;
17
+ key: string | null;
18
+ };
19
+
20
+ type SelectionElementProps = {
21
+ ref: (value: Gtk.SelectionModel | null) => void;
22
+ model: Gio.ListModel;
23
+ onSelectionChanged: () => void;
24
+ };
25
+
26
+ function getSelectedIds(selection: Gtk.SelectionModel, collection: Collection): string[] {
27
+ const bitset = selection.getSelection();
28
+ const size = Number(bitset.getSize());
29
+ const ids: string[] = [];
30
+
31
+ for (let index = 0; index < size; index++) {
32
+ pushId(collection.idAt(bitset.getNth(index)), ids);
33
+ }
34
+
35
+ return ids;
36
+ }
37
+
38
+ function pushId(id: string | null, ids: string[]): void {
39
+ if (id !== null) {
40
+ ids.push(id);
41
+ }
42
+ }
43
+
44
+ function applySingleSelection(selection: Gtk.SingleSelection, positions: number[]): void {
45
+ const [first] = positions;
46
+
47
+ if (first === undefined) {
48
+ selection.unselectAll();
49
+
50
+ return;
51
+ }
52
+
53
+ selection.selectItem(first, true);
54
+ }
55
+
56
+ function applyMultiSelection(selection: Gtk.SelectionModel, positions: number[]): void {
57
+ const selected = Gtk.Bitset.newEmpty();
58
+
59
+ for (const position of positions) {
60
+ selected.add(position);
61
+ }
62
+
63
+ selection.setSelection(selected, Gtk.Bitset.newRange(0, selection.getNItems()));
64
+ }
65
+
66
+ function applySelection(selection: Gtk.SelectionModel, collection: Collection, ids: string[]): void {
67
+ if (selection instanceof Gtk.NoSelection) {
68
+ return;
69
+ }
70
+
71
+ const positions = collection.positionsFor(ids);
72
+
73
+ if (ids.length > 0 && positions.length === 0) {
74
+ return;
75
+ }
76
+
77
+ if (selection instanceof Gtk.SingleSelection) {
78
+ applySingleSelection(selection, positions);
79
+
80
+ return;
81
+ }
82
+
83
+ applyMultiSelection(selection, positions);
84
+ }
85
+
86
+ function reportSelection(
87
+ selection: Gtk.SelectionModel | null,
88
+ collection: Collection,
89
+ last: LastSelection,
90
+ onSelectionChanged: ((ids: string[]) => void) | null | undefined,
91
+ ): void {
92
+ if (selection === null) {
93
+ return;
94
+ }
95
+
96
+ const ids = getSelectedIds(selection, collection);
97
+ const key = ids.join(" ");
98
+
99
+ if (last.selection === selection && last.key === key) {
100
+ return;
101
+ }
102
+
103
+ last.selection = selection;
104
+ last.key = key;
105
+ onSelectionChanged?.(ids);
106
+ }
107
+
108
+ function selectionElement(mode: Gtk.SelectionMode | null | undefined, props: SelectionElementProps): ReactElement {
109
+ if (mode === Gtk.SelectionMode.MULTIPLE) {
110
+ return <GtkMultiSelection {...props} />;
111
+ }
112
+
113
+ if (mode === Gtk.SelectionMode.NONE) {
114
+ return <GtkNoSelection {...props} />;
115
+ }
116
+
117
+ return <GtkSingleSelection {...props} autoselect={false} canUnselect />;
118
+ }
119
+
120
+ function syncSelection(
121
+ selection: Gtk.SelectionModel | null,
122
+ collection: Collection,
123
+ selectedIds: string[] | null | undefined,
124
+ ): void {
125
+ if (selection === null || selectedIds == null) {
126
+ return;
127
+ }
128
+
129
+ applySelection(selection, collection, selectedIds);
130
+ }
131
+
132
+ function newLastSelection(): LastSelection {
133
+ return { selection: null, key: null };
134
+ }
135
+
136
+ function useSelection(options: SelectionOptions): ReactElement {
137
+ const { collection, selectedIds, onSelectionChanged, selectionMode } = options;
138
+ const [selection, setSelection] = useState<Gtk.SelectionModel | null>(null);
139
+ const [last] = useState<LastSelection>(newLastSelection);
140
+
141
+ const report = useEffectEvent((): void => {
142
+ reportSelection(selection, collection, last, onSelectionChanged);
143
+ });
144
+
145
+ useLayoutEffect(() => {
146
+ syncSelection(selection, collection, selectedIds);
147
+ report();
148
+ }, [selection, collection, selectedIds]);
149
+
150
+ return selectionElement(selectionMode, {
151
+ ref: setSelection,
152
+ model: collection.model,
153
+ onSelectionChanged: () => {
154
+ reportSelection(selection, collection, last, onSelectionChanged);
155
+ },
156
+ });
157
+ }
158
+
159
+ export { useSelection, type SelectionOptions };
@@ -0,0 +1,70 @@
1
+ import type { ReactElement, RefObject } from "react";
2
+ import { useLayoutEffect, useMemo, useState } from "react";
3
+ import type { ExpansionProps, Item, Section, SelectionProps } from "../types.js";
4
+ import type { CollectionIndex } from "./collection-index.js";
5
+ import type { CollectionModel } from "./collection-model.js";
6
+ import type { Collection } from "./collection.js";
7
+ import { createCollectionIndex } from "./collection-index.js";
8
+ import { createCollectionModel } from "./collection-model.js";
9
+ import { createCollection } from "./collection.js";
10
+ import { useExpansion } from "./expansion.js";
11
+ import { useSelection } from "./selection.js";
12
+
13
+ type CollectionDataOptions = {
14
+ items?: Item[] | undefined;
15
+ sections?: Section[] | undefined;
16
+ flat?: boolean | undefined;
17
+ applying?: RefObject<boolean> | undefined;
18
+ };
19
+
20
+ type CollectionOptions = CollectionDataOptions & SelectionProps & ExpansionProps;
21
+
22
+ type CollectionResult = {
23
+ collection: Collection;
24
+ selection: ReactElement;
25
+ };
26
+
27
+ function runSync(gtk: CollectionModel, index: CollectionIndex, applying: RefObject<boolean> | undefined): void {
28
+ if (applying === undefined) {
29
+ gtk.sync(index);
30
+
31
+ return;
32
+ }
33
+
34
+ applying.current = true;
35
+
36
+ try {
37
+ gtk.sync(index);
38
+ } finally {
39
+ applying.current = false;
40
+ }
41
+ }
42
+
43
+ function useCollectionData(options: CollectionDataOptions): Collection {
44
+ const { items, sections, flat, applying } = options;
45
+ const [gtk] = useState(createCollectionModel);
46
+ const index = useMemo(() => createCollectionIndex(items, sections, flat === true), [items, sections, flat]);
47
+ const collection = useMemo(() => createCollection(gtk, index), [gtk, index]);
48
+
49
+ useLayoutEffect(() => {
50
+ runSync(gtk, index, applying);
51
+ }, [gtk, index, applying]);
52
+
53
+ return collection;
54
+ }
55
+
56
+ function useCollection(options: CollectionOptions): CollectionResult {
57
+ const collection = useCollectionData(options);
58
+ useExpansion({ collection, expandedIds: options.expandedIds, onExpandedChange: options.onExpandedChange });
59
+
60
+ const selection = useSelection({
61
+ collection,
62
+ selectedIds: options.selectedIds,
63
+ onSelectionChanged: options.onSelectionChanged,
64
+ selectionMode: options.selectionMode,
65
+ });
66
+
67
+ return { collection, selection };
68
+ }
69
+
70
+ export { useCollection, useCollectionData, type CollectionDataOptions, type CollectionOptions };
@@ -0,0 +1,11 @@
1
+ import type { Ref, RefCallback } from "react";
2
+ import { useMergedRef } from "@gtkx/react/internal";
3
+ import { useState } from "react";
4
+
5
+ const useWidgetRef = <T>(external: Ref<T | null> | null | undefined): [T | null, RefCallback<T | null>] => {
6
+ const [widget, setWidget] = useState<T | null>(null);
7
+
8
+ return [widget, useMergedRef<T | null>(external, setWidget)];
9
+ };
10
+
11
+ export { useWidgetRef };