@gtkx/components 1.0.0-rc.1 → 1.0.0-rc.3

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 (220) hide show
  1. package/dist/adw/combo-row.d.ts +11 -0
  2. package/dist/adw/combo-row.d.ts.map +1 -0
  3. package/dist/adw/combo-row.js +14 -0
  4. package/dist/adw/combo-row.js.map +1 -0
  5. package/dist/adw/index.d.ts +4 -0
  6. package/dist/adw/index.d.ts.map +1 -0
  7. package/dist/adw/index.js +3 -0
  8. package/dist/adw/index.js.map +1 -0
  9. package/dist/adw/toast.d.ts +10 -0
  10. package/dist/adw/toast.d.ts.map +1 -0
  11. package/dist/adw/toast.js +51 -0
  12. package/dist/adw/toast.js.map +1 -0
  13. package/dist/adw/types.d.ts +35 -0
  14. package/dist/adw/types.d.ts.map +1 -0
  15. package/dist/adw/types.js +2 -0
  16. package/dist/adw/types.js.map +1 -0
  17. package/dist/column-view.d.ts +7 -29
  18. package/dist/column-view.d.ts.map +1 -1
  19. package/dist/column-view.js +98 -70
  20. package/dist/column-view.js.map +1 -1
  21. package/dist/drop-down.d.ts +7 -28
  22. package/dist/drop-down.d.ts.map +1 -1
  23. package/dist/drop-down.js +10 -86
  24. package/dist/drop-down.js.map +1 -1
  25. package/dist/grid-view.d.ts +6 -16
  26. package/dist/grid-view.d.ts.map +1 -1
  27. package/dist/grid-view.js +25 -33
  28. package/dist/grid-view.js.map +1 -1
  29. package/dist/index.d.ts +6 -12
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -6
  32. package/dist/index.js.map +1 -1
  33. package/dist/internal/cells.d.ts +50 -0
  34. package/dist/internal/cells.d.ts.map +1 -0
  35. package/dist/internal/cells.js +185 -0
  36. package/dist/internal/cells.js.map +1 -0
  37. package/dist/internal/collection-index.d.ts +21 -0
  38. package/dist/internal/collection-index.d.ts.map +1 -0
  39. package/dist/internal/collection-index.js +106 -0
  40. package/dist/internal/collection-index.js.map +1 -0
  41. package/dist/internal/collection-model.d.ts +12 -0
  42. package/dist/internal/collection-model.d.ts.map +1 -0
  43. package/dist/internal/collection-model.js +164 -0
  44. package/dist/internal/collection-model.js.map +1 -0
  45. package/dist/internal/collection.d.ts +18 -0
  46. package/dist/internal/collection.d.ts.map +1 -0
  47. package/dist/internal/collection.js +64 -0
  48. package/dist/internal/collection.js.map +1 -0
  49. package/dist/internal/drop-down.d.ts +16 -0
  50. package/dist/internal/drop-down.d.ts.map +1 -0
  51. package/dist/internal/drop-down.js +110 -0
  52. package/dist/internal/drop-down.js.map +1 -0
  53. package/dist/internal/expansion.d.ts +9 -0
  54. package/dist/internal/expansion.d.ts.map +1 -0
  55. package/dist/internal/expansion.js +74 -0
  56. package/dist/internal/expansion.js.map +1 -0
  57. package/dist/internal/selection.d.ts +13 -0
  58. package/dist/internal/selection.d.ts.map +1 -0
  59. package/dist/internal/selection.js +100 -0
  60. package/dist/internal/selection.js.map +1 -0
  61. package/dist/internal/use-collection.d.ts +17 -0
  62. package/dist/internal/use-collection.d.ts.map +1 -0
  63. package/dist/internal/use-collection.js +34 -0
  64. package/dist/internal/use-collection.js.map +1 -0
  65. package/dist/internal/use-widget-ref.d.ts +4 -0
  66. package/dist/internal/use-widget-ref.d.ts.map +1 -0
  67. package/dist/internal/use-widget-ref.js +8 -0
  68. package/dist/internal/use-widget-ref.js.map +1 -0
  69. package/dist/list-view.d.ts +5 -20
  70. package/dist/list-view.d.ts.map +1 -1
  71. package/dist/list-view.js +32 -53
  72. package/dist/list-view.js.map +1 -1
  73. package/dist/size-group.d.ts +8 -16
  74. package/dist/size-group.d.ts.map +1 -1
  75. package/dist/size-group.js +35 -51
  76. package/dist/size-group.js.map +1 -1
  77. package/dist/types.d.ts +98 -27
  78. package/dist/types.d.ts.map +1 -1
  79. package/dist/types.js.map +1 -1
  80. package/package.json +17 -6
  81. package/src/adw/combo-row.tsx +20 -0
  82. package/src/adw/index.ts +9 -0
  83. package/src/adw/toast.tsx +74 -0
  84. package/src/adw/types.ts +45 -0
  85. package/src/column-view.tsx +176 -160
  86. package/src/drop-down.tsx +12 -185
  87. package/src/grid-view.tsx +29 -73
  88. package/src/index.ts +18 -26
  89. package/src/internal/cells.tsx +345 -0
  90. package/src/internal/collection-index.ts +168 -0
  91. package/src/internal/collection-model.ts +246 -0
  92. package/src/internal/collection.ts +104 -0
  93. package/src/internal/drop-down.tsx +186 -0
  94. package/src/internal/expansion.ts +122 -0
  95. package/src/internal/selection.tsx +162 -0
  96. package/src/internal/use-collection.tsx +57 -0
  97. package/src/internal/use-widget-ref.ts +11 -0
  98. package/src/list-view.tsx +43 -139
  99. package/src/size-group.tsx +63 -82
  100. package/src/types.ts +158 -28
  101. package/dist/cell.d.ts +0 -30
  102. package/dist/cell.d.ts.map +0 -1
  103. package/dist/cell.js +0 -45
  104. package/dist/cell.js.map +0 -1
  105. package/dist/column-view-column.d.ts +0 -27
  106. package/dist/column-view-column.d.ts.map +0 -1
  107. package/dist/column-view-column.js +0 -30
  108. package/dist/column-view-column.js.map +0 -1
  109. package/dist/constraint-layout-apply.d.ts +0 -55
  110. package/dist/constraint-layout-apply.d.ts.map +0 -1
  111. package/dist/constraint-layout-apply.js +0 -107
  112. package/dist/constraint-layout-apply.js.map +0 -1
  113. package/dist/constraint-layout.d.ts +0 -20
  114. package/dist/constraint-layout.d.ts.map +0 -1
  115. package/dist/constraint-layout.js +0 -88
  116. package/dist/constraint-layout.js.map +0 -1
  117. package/dist/fixed.d.ts +0 -26
  118. package/dist/fixed.d.ts.map +0 -1
  119. package/dist/fixed.js +0 -40
  120. package/dist/fixed.js.map +0 -1
  121. package/dist/grid.d.ts +0 -27
  122. package/dist/grid.d.ts.map +0 -1
  123. package/dist/grid.js +0 -48
  124. package/dist/grid.js.map +0 -1
  125. package/dist/hooks/use-cell-containers.d.ts +0 -18
  126. package/dist/hooks/use-cell-containers.d.ts.map +0 -1
  127. package/dist/hooks/use-cell-containers.js +0 -67
  128. package/dist/hooks/use-cell-containers.js.map +0 -1
  129. package/dist/hooks/use-collection-model.d.ts +0 -26
  130. package/dist/hooks/use-collection-model.d.ts.map +0 -1
  131. package/dist/hooks/use-collection-model.js +0 -35
  132. package/dist/hooks/use-collection-model.js.map +0 -1
  133. package/dist/hooks/use-collection-widget.d.ts +0 -28
  134. package/dist/hooks/use-collection-widget.d.ts.map +0 -1
  135. package/dist/hooks/use-collection-widget.js +0 -21
  136. package/dist/hooks/use-collection-widget.js.map +0 -1
  137. package/dist/hooks/use-drop-down-selection.d.ts +0 -16
  138. package/dist/hooks/use-drop-down-selection.d.ts.map +0 -1
  139. package/dist/hooks/use-drop-down-selection.js +0 -35
  140. package/dist/hooks/use-drop-down-selection.js.map +0 -1
  141. package/dist/hooks/use-expansion-model.d.ts +0 -12
  142. package/dist/hooks/use-expansion-model.d.ts.map +0 -1
  143. package/dist/hooks/use-expansion-model.js +0 -71
  144. package/dist/hooks/use-expansion-model.js.map +0 -1
  145. package/dist/hooks/use-header-menu.d.ts +0 -4
  146. package/dist/hooks/use-header-menu.d.ts.map +0 -1
  147. package/dist/hooks/use-header-menu.js +0 -20
  148. package/dist/hooks/use-header-menu.js.map +0 -1
  149. package/dist/hooks/use-installed-model.d.ts +0 -4
  150. package/dist/hooks/use-installed-model.d.ts.map +0 -1
  151. package/dist/hooks/use-installed-model.js +0 -15
  152. package/dist/hooks/use-installed-model.js.map +0 -1
  153. package/dist/hooks/use-list-model.d.ts +0 -19
  154. package/dist/hooks/use-list-model.d.ts.map +0 -1
  155. package/dist/hooks/use-list-model.js +0 -97
  156. package/dist/hooks/use-list-model.js.map +0 -1
  157. package/dist/hooks/use-placed-child.d.ts +0 -18
  158. package/dist/hooks/use-placed-child.d.ts.map +0 -1
  159. package/dist/hooks/use-placed-child.js +0 -47
  160. package/dist/hooks/use-placed-child.js.map +0 -1
  161. package/dist/hooks/use-selection-model.d.ts +0 -13
  162. package/dist/hooks/use-selection-model.d.ts.map +0 -1
  163. package/dist/hooks/use-selection-model.js +0 -103
  164. package/dist/hooks/use-selection-model.js.map +0 -1
  165. package/dist/hooks/use-sort-handler.d.ts +0 -17
  166. package/dist/hooks/use-sort-handler.d.ts.map +0 -1
  167. package/dist/hooks/use-sort-handler.js +0 -87
  168. package/dist/hooks/use-sort-handler.js.map +0 -1
  169. package/dist/menu-items.d.ts +0 -5
  170. package/dist/menu-items.d.ts.map +0 -1
  171. package/dist/menu-items.js +0 -48
  172. package/dist/menu-items.js.map +0 -1
  173. package/dist/menu.d.ts +0 -14
  174. package/dist/menu.d.ts.map +0 -1
  175. package/dist/menu.js +0 -24
  176. package/dist/menu.js.map +0 -1
  177. package/dist/overlay.d.ts +0 -24
  178. package/dist/overlay.d.ts.map +0 -1
  179. package/dist/overlay.js +0 -49
  180. package/dist/overlay.js.map +0 -1
  181. package/dist/utils/cell-container-store.d.ts +0 -28
  182. package/dist/utils/cell-container-store.d.ts.map +0 -1
  183. package/dist/utils/cell-container-store.js +0 -98
  184. package/dist/utils/cell-container-store.js.map +0 -1
  185. package/dist/utils/item-models.d.ts +0 -10
  186. package/dist/utils/item-models.d.ts.map +0 -1
  187. package/dist/utils/item-models.js +0 -82
  188. package/dist/utils/item-models.js.map +0 -1
  189. package/dist/utils/item-resolver.d.ts +0 -25
  190. package/dist/utils/item-resolver.d.ts.map +0 -1
  191. package/dist/utils/item-resolver.js +0 -102
  192. package/dist/utils/item-resolver.js.map +0 -1
  193. package/dist/utils/list-item-flatten.d.ts +0 -24
  194. package/dist/utils/list-item-flatten.d.ts.map +0 -1
  195. package/dist/utils/list-item-flatten.js +0 -68
  196. package/dist/utils/list-item-flatten.js.map +0 -1
  197. package/src/cell.tsx +0 -144
  198. package/src/column-view-column.tsx +0 -82
  199. package/src/constraint-layout-apply.ts +0 -173
  200. package/src/constraint-layout.tsx +0 -135
  201. package/src/fixed.tsx +0 -86
  202. package/src/grid.tsx +0 -88
  203. package/src/hooks/use-cell-containers.ts +0 -93
  204. package/src/hooks/use-collection-model.ts +0 -60
  205. package/src/hooks/use-collection-widget.ts +0 -47
  206. package/src/hooks/use-drop-down-selection.ts +0 -52
  207. package/src/hooks/use-expansion-model.ts +0 -82
  208. package/src/hooks/use-header-menu.ts +0 -34
  209. package/src/hooks/use-installed-model.ts +0 -20
  210. package/src/hooks/use-list-model.ts +0 -161
  211. package/src/hooks/use-placed-child.ts +0 -79
  212. package/src/hooks/use-selection-model.ts +0 -114
  213. package/src/hooks/use-sort-handler.ts +0 -107
  214. package/src/menu-items.ts +0 -48
  215. package/src/menu.tsx +0 -32
  216. package/src/overlay.tsx +0 -88
  217. package/src/utils/cell-container-store.ts +0 -114
  218. package/src/utils/item-models.ts +0 -113
  219. package/src/utils/item-resolver.ts +0 -138
  220. package/src/utils/list-item-flatten.ts +0 -90
@@ -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,186 @@
1
+ import type * as GObject from "@gtkx/gi/gobject";
2
+ import type { ElementType, ReactNode, Ref } from "react";
3
+ import { GtkLabel, GtkSignalListItemFactory } from "@gtkx/jsx/gtk";
4
+ import { applyWrite } from "@gtkx/react/internal";
5
+ import { omit } from "@gtkx/utils";
6
+ import { useEffectEvent, useLayoutEffect, useRef } from "react";
7
+ import type { DropDownOwnProps, ItemRenderer, RenderItemArgs } from "../types.js";
8
+ import type { Collection } from "./collection.js";
9
+ import { HeaderPortals, ItemPortals, useHeaderCells, useItemCells } from "./cells.js";
10
+ import { useCollectionData } from "./use-collection.js";
11
+ import { useWidgetRef } from "./use-widget-ref.js";
12
+
13
+ type SelectableWidget = GObject.Object & { getSelected: () => number; selected: number };
14
+
15
+ type DropDownBaseProps = DropDownOwnProps<unknown, unknown> & {
16
+ onNotifySelected?: ((value: number | null, self: SelectableWidget) => void) | null | undefined;
17
+ ref?: Ref<SelectableWidget | null> | undefined;
18
+ } & Record<string, unknown>;
19
+
20
+ type SelectionOptions = {
21
+ widget: SelectableWidget | null;
22
+ collection: Collection;
23
+ props: DropDownBaseProps;
24
+ };
25
+
26
+ type NotifySelectedHandler = NonNullable<DropDownBaseProps["onNotifySelected"]>;
27
+
28
+ type KnownSelection = {
29
+ known: { current: string | null };
30
+ onSelectionChanged: ((id: string) => void) | null | undefined;
31
+ };
32
+
33
+ const DROP_DOWN_PROPS: string[] = [
34
+ "component",
35
+ "items",
36
+ "sections",
37
+ "selectedId",
38
+ "onSelectionChanged",
39
+ "onNotifySelected",
40
+ "renderItem",
41
+ "renderListItem",
42
+ "renderHeader",
43
+ "ref",
44
+ ];
45
+
46
+ const describeValue = (value: unknown): string => {
47
+ if (typeof value === "string") {
48
+ return value;
49
+ }
50
+
51
+ try {
52
+ return JSON.stringify(value);
53
+ } catch {
54
+ return String(value);
55
+ }
56
+ };
57
+
58
+ const defaultItemContent = (value: unknown): ReactNode =>
59
+ value == null ? null : <GtkLabel>{describeValue(value)}</GtkLabel>;
60
+
61
+ const defaultRenderItem = ({ item }: RenderItemArgs<unknown>): ReactNode => defaultItemContent(item);
62
+ const faceRenderer = (props: DropDownBaseProps): ItemRenderer<never> => props.renderItem ?? defaultRenderItem;
63
+
64
+ const listRenderer = (props: DropDownBaseProps): ItemRenderer<never> =>
65
+ props.renderListItem ?? props.renderItem ?? defaultRenderItem;
66
+
67
+ const resolvePosition = (
68
+ widget: SelectableWidget,
69
+ collection: Collection,
70
+ selectedId: string | null | undefined,
71
+ ): number => {
72
+ const requested = selectedId == null ? -1 : collection.positionFor(selectedId);
73
+
74
+ return requested >= 0 ? requested : widget.getSelected();
75
+ };
76
+
77
+ const updateKnownSelection = (
78
+ tracker: KnownSelection,
79
+ effectiveId: string,
80
+ selectedId: string | null | undefined,
81
+ ): void => {
82
+ const { known, onSelectionChanged } = tracker;
83
+ const expectedId = selectedId ?? known.current;
84
+ const isNew = effectiveId !== known.current;
85
+ known.current = effectiveId;
86
+
87
+ if (expectedId !== null && effectiveId !== expectedId && isNew) {
88
+ onSelectionChanged?.(effectiveId);
89
+ }
90
+ };
91
+
92
+ const reportKnownSelection = (tracker: KnownSelection, id: string | null): void => {
93
+ const { known, onSelectionChanged } = tracker;
94
+
95
+ if (id === null || id === known.current) {
96
+ return;
97
+ }
98
+
99
+ known.current = id;
100
+ onSelectionChanged?.(id);
101
+ };
102
+
103
+ const reportSelectedNotify = (options: SelectionOptions, tracker: KnownSelection): void => {
104
+ const { widget, collection } = options;
105
+
106
+ if (widget === null) {
107
+ return;
108
+ }
109
+
110
+ reportKnownSelection(tracker, collection.idAt(widget.getSelected()));
111
+ };
112
+
113
+ const applySelectedPosition = (widget: SelectableWidget, position: number): void => {
114
+ applyWrite(() => {
115
+ widget.selected = position;
116
+ });
117
+ };
118
+
119
+ const useDropDownSelection = (options: SelectionOptions): NotifySelectedHandler => {
120
+ const { widget, collection } = options;
121
+ const { selectedId } = options.props;
122
+ const known = useRef<string | null>(null);
123
+
124
+ const syncKnownSelection = useEffectEvent((position: number): void => {
125
+ const effectiveId = collection.idAt(position);
126
+
127
+ if (effectiveId === null) {
128
+ known.current = null;
129
+
130
+ return;
131
+ }
132
+
133
+ const { onSelectionChanged } = options.props;
134
+ updateKnownSelection({ known, onSelectionChanged }, effectiveId, selectedId);
135
+ });
136
+
137
+ useLayoutEffect(() => {
138
+ if (widget === null) {
139
+ return;
140
+ }
141
+
142
+ const position = resolvePosition(widget, collection, selectedId);
143
+ applySelectedPosition(widget, position);
144
+ syncKnownSelection(position);
145
+ }, [widget, collection, selectedId]);
146
+
147
+ return (value, self) => {
148
+ reportSelectedNotify(options, { known, onSelectionChanged: options.props.onSelectionChanged });
149
+ options.props.onNotifySelected?.(value, self);
150
+ };
151
+ };
152
+
153
+ function DropDownBase(props: DropDownBaseProps & { component: ElementType }): ReactNode {
154
+ const { component: Component, items, sections, renderListItem, renderHeader, ref } = props;
155
+ const rest = omit(props, DROP_DOWN_PROPS);
156
+ const [widget, refCallback] = useWidgetRef<SelectableWidget>(ref);
157
+ const collection = useCollectionData({ items, sections });
158
+ const faceCells = useItemCells({ width: -1, height: -1 });
159
+ const listCells = useItemCells({ width: -1, height: -1 });
160
+ const headerCells = useHeaderCells({ width: -1, height: -1 });
161
+ const handleNotifySelected = useDropDownSelection({ widget, collection, props });
162
+ const hasHeader = typeof renderHeader === "function";
163
+
164
+ return (
165
+ <>
166
+ <Component
167
+ ref={refCallback}
168
+ model={collection.model}
169
+ factory={<GtkSignalListItemFactory {...faceCells.handlers} />}
170
+ {...(renderListItem != null && {
171
+ listFactory: <GtkSignalListItemFactory {...listCells.handlers} />,
172
+ })}
173
+ {...(hasHeader && { headerFactory: <GtkSignalListItemFactory {...headerCells.handlers} /> })}
174
+ {...rest}
175
+ onNotifySelected={handleNotifySelected}
176
+ />
177
+ <ItemPortals store={faceCells} render={faceRenderer(props)} collection={collection} />
178
+ {renderListItem != null && (
179
+ <ItemPortals store={listCells} render={listRenderer(props)} collection={collection} />
180
+ )}
181
+ {hasHeader && <HeaderPortals store={headerCells} render={renderHeader} collection={collection} />}
182
+ </>
183
+ );
184
+ }
185
+
186
+ export { DropDownBase, type DropDownBaseProps };
@@ -0,0 +1,122 @@
1
+ import type * as Gtk from "@gtkx/gi/gtk";
2
+ import type { RefObject } from "react";
3
+ import { useEffectEvent, useLayoutEffect, useRef, useState } from "react";
4
+ import type { Collection } from "./collection.js";
5
+ import { eachRow } from "./collection.js";
6
+
7
+ type ExpansionOptions = {
8
+ collection: Collection;
9
+ expandedIds?: string[] | null | undefined;
10
+ onExpandedChange?: ((ids: string[]) => void) | null | undefined;
11
+ };
12
+
13
+ type LastExpansion = { key: string };
14
+
15
+ function newLastExpansion(): LastExpansion {
16
+ return { key: "" };
17
+ }
18
+
19
+ function collectExpanded(row: Gtk.TreeListRow, id: string | null, ids: string[]): void {
20
+ if (id !== null && row.getExpanded()) {
21
+ ids.push(id);
22
+ }
23
+ }
24
+
25
+ function expandRow(row: Gtk.TreeListRow, id: string | null, wanted: Set<string>): void {
26
+ const isDesired = id !== null && wanted.has(id);
27
+
28
+ if (row.isExpandable() && row.getExpanded() !== isDesired) {
29
+ row.setExpanded(isDesired);
30
+ }
31
+ }
32
+
33
+ function applyExpansion(tree: Gtk.TreeListModel, expandedIds: string[]): void {
34
+ const wanted = new Set(expandedIds);
35
+
36
+ eachRow(tree, (row, id) => {
37
+ expandRow(row, id, wanted);
38
+ });
39
+ }
40
+
41
+ function reportExpansion(
42
+ collection: Collection,
43
+ last: LastExpansion,
44
+ onExpandedChange: ((ids: string[]) => void) | null | undefined,
45
+ ): void {
46
+ const tree = collection.treeModel();
47
+
48
+ if (tree === null) {
49
+ return;
50
+ }
51
+
52
+ const ids: string[] = [];
53
+
54
+ eachRow(tree, (row, id) => {
55
+ collectExpanded(row, id, ids);
56
+ });
57
+
58
+ const key = ids.join(" ");
59
+
60
+ if (last.key === key) {
61
+ return;
62
+ }
63
+
64
+ last.key = key;
65
+ onExpandedChange?.(ids);
66
+ }
67
+
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) {
75
+ return;
76
+ }
77
+
78
+ reportExpansion(collection, last, onExpandedChange);
79
+ }
80
+
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();
88
+
89
+ if (tree === null || expandedIds == null) {
90
+ return;
91
+ }
92
+
93
+ expanding.current = true;
94
+
95
+ try {
96
+ applyExpansion(tree, expandedIds);
97
+ } finally {
98
+ expanding.current = false;
99
+ }
100
+
101
+ report();
102
+ }
103
+
104
+ function useExpansion(options: ExpansionOptions): () => void {
105
+ const { collection, expandedIds, onExpandedChange } = options;
106
+ const [last] = useState<LastExpansion>(newLastExpansion);
107
+ const expanding = useRef(false);
108
+
109
+ const report = useEffectEvent((): void => {
110
+ reportWhenIdle(collection, last, expanding, onExpandedChange);
111
+ });
112
+
113
+ useLayoutEffect(() => {
114
+ runControlledExpansion(collection, expandedIds, expanding, report);
115
+ }, [collection, expandedIds]);
116
+
117
+ return () => {
118
+ reportWhenIdle(collection, last, expanding, onExpandedChange);
119
+ };
120
+ }
121
+
122
+ export { useExpansion, type ExpansionOptions };