@gtkx/components 1.0.0-rc.4 → 1.1.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.
- package/README.md +5 -5
- package/dist/column-view.d.ts +2 -2
- package/dist/column-view.d.ts.map +1 -1
- package/dist/column-view.js +14 -12
- package/dist/column-view.js.map +1 -1
- package/dist/grid-view.js +1 -1
- package/dist/grid-view.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/cells.d.ts +31 -23
- package/dist/internal/cells.d.ts.map +1 -1
- package/dist/internal/cells.js +168 -77
- package/dist/internal/cells.js.map +1 -1
- package/dist/internal/collection-index.d.ts +8 -11
- package/dist/internal/collection-index.d.ts.map +1 -1
- package/dist/internal/collection-index.js +53 -73
- package/dist/internal/collection-index.js.map +1 -1
- package/dist/internal/collection-model.d.ts +24 -4
- package/dist/internal/collection-model.d.ts.map +1 -1
- package/dist/internal/collection-model.js +247 -96
- package/dist/internal/collection-model.js.map +1 -1
- package/dist/internal/collection.d.ts +8 -10
- package/dist/internal/collection.d.ts.map +1 -1
- package/dist/internal/collection.js +38 -48
- package/dist/internal/collection.js.map +1 -1
- package/dist/internal/controlled-sync.d.ts +11 -0
- package/dist/internal/controlled-sync.d.ts.map +1 -0
- package/dist/internal/controlled-sync.js +19 -0
- package/dist/internal/controlled-sync.js.map +1 -0
- package/dist/internal/drop-down.d.ts.map +1 -1
- package/dist/internal/drop-down.js +27 -15
- package/dist/internal/drop-down.js.map +1 -1
- package/dist/internal/expansion.d.ts +3 -2
- package/dist/internal/expansion.d.ts.map +1 -1
- package/dist/internal/expansion.js +73 -48
- package/dist/internal/expansion.js.map +1 -1
- package/dist/internal/keys.d.ts +5 -0
- package/dist/internal/keys.d.ts.map +1 -0
- package/dist/internal/keys.js +13 -0
- package/dist/internal/keys.js.map +1 -0
- package/dist/internal/selection.d.ts +3 -2
- package/dist/internal/selection.d.ts.map +1 -1
- package/dist/internal/selection.js +68 -35
- package/dist/internal/selection.js.map +1 -1
- package/dist/internal/slots.d.ts +10 -0
- package/dist/internal/slots.d.ts.map +1 -0
- package/dist/internal/slots.js +43 -0
- package/dist/internal/slots.js.map +1 -0
- package/dist/internal/tree-expansion.d.ts +19 -0
- package/dist/internal/tree-expansion.d.ts.map +1 -0
- package/dist/internal/tree-expansion.js +72 -0
- package/dist/internal/tree-expansion.js.map +1 -0
- package/dist/internal/tree-order.d.ts +26 -0
- package/dist/internal/tree-order.d.ts.map +1 -0
- package/dist/internal/tree-order.js +109 -0
- package/dist/internal/tree-order.js.map +1 -0
- package/dist/internal/use-collection.d.ts +1 -1
- package/dist/internal/use-collection.d.ts.map +1 -1
- package/dist/internal/use-collection.js +4 -4
- package/dist/internal/use-collection.js.map +1 -1
- package/dist/list-view.d.ts.map +1 -1
- package/dist/list-view.js +6 -6
- package/dist/list-view.js.map +1 -1
- package/dist/types.d.ts +72 -22
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +17 -9
- package/src/column-view.tsx +28 -25
- package/src/grid-view.tsx +1 -1
- package/src/index.ts +3 -0
- package/src/internal/cells.tsx +339 -117
- package/src/internal/collection-index.ts +68 -92
- package/src/internal/collection-model.ts +327 -112
- package/src/internal/collection.ts +45 -65
- package/src/internal/controlled-sync.ts +34 -0
- package/src/internal/drop-down.tsx +41 -20
- package/src/internal/expansion.ts +95 -70
- package/src/internal/keys.ts +18 -0
- package/src/internal/selection.tsx +100 -51
- package/src/internal/slots.ts +67 -0
- package/src/internal/tree-expansion.ts +115 -0
- package/src/internal/tree-order.ts +208 -0
- package/src/internal/use-collection.tsx +5 -5
- package/src/list-view.tsx +14 -10
- package/src/types.ts +78 -20
|
@@ -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 {
|
|
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:
|
|
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:
|
|
36
|
+
onItemsChanged: ItemsChangeHandler;
|
|
26
37
|
};
|
|
27
38
|
|
|
28
|
-
function
|
|
39
|
+
function selectedPositions(selection: Gtk.SelectionModel): number[] {
|
|
29
40
|
const bitset = selection.getSelection();
|
|
30
41
|
const size = Number(bitset.getSize());
|
|
31
|
-
const
|
|
42
|
+
const positions: number[] = [];
|
|
32
43
|
|
|
33
44
|
for (let index = 0; index < size; index++) {
|
|
34
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
123
|
-
selection
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
205
|
+
observeSelection(context, selectedIds, markDrift);
|
|
157
206
|
},
|
|
158
207
|
onItemsChanged: options.onItemsChanged,
|
|
159
208
|
});
|
|
160
209
|
}
|
|
161
210
|
|
|
162
|
-
export { useSelection
|
|
211
|
+
export { useSelection };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { decodePartAt, encodePart } from "./keys.js";
|
|
2
|
+
|
|
3
|
+
type SlotKey = {
|
|
4
|
+
levelPath: string;
|
|
5
|
+
slot: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type SlotMap = Map<string, Set<number>>;
|
|
9
|
+
|
|
10
|
+
function getLevelBoundary(path: string): number {
|
|
11
|
+
let offset = 0;
|
|
12
|
+
let boundary = 0;
|
|
13
|
+
|
|
14
|
+
while (offset < path.length) {
|
|
15
|
+
const part = decodePartAt(path, offset);
|
|
16
|
+
|
|
17
|
+
if (part === null) {
|
|
18
|
+
return boundary;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
boundary = offset;
|
|
22
|
+
offset += encodePart(part).length;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return boundary;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getSlotKey(path: string): SlotKey | null {
|
|
29
|
+
const boundary = getLevelBoundary(path);
|
|
30
|
+
const part = decodePartAt(path, boundary);
|
|
31
|
+
|
|
32
|
+
if (part === null) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { levelPath: path.slice(0, boundary), slot: Number(part) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function trackPath(slots: SlotMap, path: string): void {
|
|
40
|
+
const key = getSlotKey(path);
|
|
41
|
+
|
|
42
|
+
if (key === null) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const level = slots.get(key.levelPath);
|
|
47
|
+
|
|
48
|
+
if (level === undefined) {
|
|
49
|
+
slots.set(key.levelPath, new Set([key.slot]));
|
|
50
|
+
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
level.add(key.slot);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function trackPaths(paths: Iterable<string>): SlotMap {
|
|
58
|
+
const slots: SlotMap = new Map();
|
|
59
|
+
|
|
60
|
+
for (const path of paths) {
|
|
61
|
+
trackPath(slots, path);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return slots;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { getSlotKey, trackPath, trackPaths, type SlotMap };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { CollectionIndex } from "./collection-index.js";
|
|
2
|
+
import type { SlotMap } from "./slots.js";
|
|
3
|
+
import type { VisibleOrder } from "./tree-order.js";
|
|
4
|
+
import { encodePart } from "./keys.js";
|
|
5
|
+
import { getSlotKey, trackPath, trackPaths } from "./slots.js";
|
|
6
|
+
import { buildVisibleOrder } from "./tree-order.js";
|
|
7
|
+
|
|
8
|
+
type TreeExpansion = {
|
|
9
|
+
index: CollectionIndex;
|
|
10
|
+
expanded: Set<string>;
|
|
11
|
+
slots: SlotMap;
|
|
12
|
+
order: VisibleOrder | null;
|
|
13
|
+
isApplying: boolean;
|
|
14
|
+
isSyncing: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function dropLevel(expansion: TreeExpansion, path: string, pending: string[]): void {
|
|
18
|
+
expansion.expanded.delete(path);
|
|
19
|
+
const level = expansion.slots.get(path);
|
|
20
|
+
|
|
21
|
+
if (level === undefined) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
expansion.slots.delete(path);
|
|
26
|
+
|
|
27
|
+
for (const slot of level) {
|
|
28
|
+
pending.push(path + encodePart(String(slot)));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function dropSubtree(expansion: TreeExpansion, path: string): void {
|
|
33
|
+
const pending: string[] = [path];
|
|
34
|
+
|
|
35
|
+
while (pending.length > 0) {
|
|
36
|
+
const current = pending.pop();
|
|
37
|
+
|
|
38
|
+
if (current !== undefined) {
|
|
39
|
+
dropLevel(expansion, current, pending);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createTreeExpansion(index: CollectionIndex): TreeExpansion {
|
|
45
|
+
return { index, expanded: new Set(), slots: new Map(), order: null, isApplying: false, isSyncing: false };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function orderFor(expansion: TreeExpansion): VisibleOrder {
|
|
49
|
+
expansion.order ??= buildVisibleOrder(expansion.index, expansion.slots);
|
|
50
|
+
|
|
51
|
+
return expansion.order;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function adoptOrder(expansion: TreeExpansion, order: VisibleOrder): void {
|
|
55
|
+
expansion.expanded = new Set(order.expandedPaths);
|
|
56
|
+
expansion.slots = trackPaths(order.expandedPaths);
|
|
57
|
+
expansion.order = order;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function prunePath(expansion: TreeExpansion, path: string): void {
|
|
61
|
+
const key = getSlotKey(path);
|
|
62
|
+
|
|
63
|
+
if (key !== null) {
|
|
64
|
+
expansion.slots.get(key.levelPath)?.delete(key.slot);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
dropSubtree(expansion, path);
|
|
68
|
+
expansion.order = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pruneSlots(expansion: TreeExpansion, levelPath: string, isPruned: (slot: number) => boolean): void {
|
|
72
|
+
const level = expansion.slots.get(levelPath);
|
|
73
|
+
|
|
74
|
+
if (level === undefined) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const slot of level) {
|
|
79
|
+
if (!isPruned(slot)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
level.delete(slot);
|
|
84
|
+
dropSubtree(expansion, levelPath + encodePart(String(slot)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
expansion.order = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function markExpanded(expansion: TreeExpansion, path: string, isExpanded: boolean): void {
|
|
91
|
+
if (isExpanded) {
|
|
92
|
+
expansion.expanded.add(path);
|
|
93
|
+
trackPath(expansion.slots, path);
|
|
94
|
+
expansion.order = null;
|
|
95
|
+
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
prunePath(expansion, path);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function adoptIndex(expansion: TreeExpansion, index: CollectionIndex): void {
|
|
103
|
+
expansion.index = index;
|
|
104
|
+
expansion.order = null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export {
|
|
108
|
+
adoptIndex,
|
|
109
|
+
adoptOrder,
|
|
110
|
+
createTreeExpansion,
|
|
111
|
+
markExpanded,
|
|
112
|
+
orderFor,
|
|
113
|
+
pruneSlots,
|
|
114
|
+
type TreeExpansion,
|
|
115
|
+
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { ListItem } from "../types.js";
|
|
2
|
+
import type { CollectionIndex, Level } from "./collection-index.js";
|
|
3
|
+
import type { SlotMap } from "./slots.js";
|
|
4
|
+
|
|
5
|
+
type VisibleRow = {
|
|
6
|
+
item: ListItem;
|
|
7
|
+
position: number;
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
isMarked: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type VisibleOrder = {
|
|
13
|
+
expandedPaths: string[];
|
|
14
|
+
expandedIds: string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type RowVisitor = (row: VisibleRow) => void;
|
|
18
|
+
|
|
19
|
+
type WalkOptions = {
|
|
20
|
+
index: CollectionIndex;
|
|
21
|
+
slots: SlotMap;
|
|
22
|
+
marks?: SlotMap | undefined;
|
|
23
|
+
visit?: RowVisitor | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type LevelFrame = {
|
|
27
|
+
level: Level;
|
|
28
|
+
cursor: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type WalkFrame = LevelFrame & {
|
|
32
|
+
open: Set<number> | undefined;
|
|
33
|
+
marked: Set<number> | undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type WalkState = {
|
|
37
|
+
options: WalkOptions;
|
|
38
|
+
stack: WalkFrame[];
|
|
39
|
+
row: VisibleRow;
|
|
40
|
+
order: VisibleOrder;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type OpenFrame = LevelFrame & {
|
|
44
|
+
owner: ListItem | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type OpenWalk = {
|
|
48
|
+
index: CollectionIndex;
|
|
49
|
+
ids: Set<string>;
|
|
50
|
+
paths: Set<string>;
|
|
51
|
+
stack: OpenFrame[];
|
|
52
|
+
ancestors: Set<ListItem>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const NO_ITEM: ListItem = { id: "", value: undefined };
|
|
56
|
+
|
|
57
|
+
function advanceStack<F extends LevelFrame>(
|
|
58
|
+
stack: F[],
|
|
59
|
+
onSlot: (frame: F, slot: number) => void,
|
|
60
|
+
onLeave?: (frame: F) => void,
|
|
61
|
+
): void {
|
|
62
|
+
const frame = stack.at(-1);
|
|
63
|
+
|
|
64
|
+
if (frame === undefined) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (frame.cursor >= frame.level.items.length) {
|
|
69
|
+
stack.pop();
|
|
70
|
+
onLeave?.(frame);
|
|
71
|
+
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const slot = frame.cursor;
|
|
76
|
+
frame.cursor += 1;
|
|
77
|
+
onSlot(frame, slot);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function frameFor(options: WalkOptions, level: Level): WalkFrame {
|
|
81
|
+
return { level, cursor: 0, open: options.slots.get(level.path), marked: options.marks?.get(level.path) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function descend(state: WalkState, level: Level, slot: number): void {
|
|
85
|
+
const child = state.options.index.childLevel(level, slot);
|
|
86
|
+
|
|
87
|
+
if (child === undefined) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
state.order.expandedPaths.push(child.path);
|
|
92
|
+
state.order.expandedIds.push(state.row.item.id);
|
|
93
|
+
state.stack.push(frameFor(state.options, child));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function visitSlot(state: WalkState, frame: WalkFrame, slot: number): void {
|
|
97
|
+
const item = frame.level.items[slot];
|
|
98
|
+
|
|
99
|
+
if (item === undefined) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { row } = state;
|
|
104
|
+
row.item = item;
|
|
105
|
+
row.isOpen = frame.open?.has(slot) ?? false;
|
|
106
|
+
row.isMarked = frame.marked?.has(slot) ?? false;
|
|
107
|
+
state.options.visit?.(row);
|
|
108
|
+
row.position += 1;
|
|
109
|
+
|
|
110
|
+
if (row.isOpen) {
|
|
111
|
+
descend(state, frame.level, slot);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function walkVisible(options: WalkOptions): VisibleOrder {
|
|
116
|
+
const order: VisibleOrder = { expandedPaths: [], expandedIds: [] };
|
|
117
|
+
const row: VisibleRow = { item: NO_ITEM, position: 0, isOpen: false, isMarked: false };
|
|
118
|
+
const frames = options.index.groups.map((level) => frameFor(options, level));
|
|
119
|
+
const state: WalkState = { options, stack: frames.toReversed(), row, order };
|
|
120
|
+
|
|
121
|
+
const onSlot = (frame: WalkFrame, slot: number): void => {
|
|
122
|
+
visitSlot(state, frame, slot);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
while (state.stack.length > 0) {
|
|
126
|
+
advanceStack(state.stack, onSlot);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return order;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function openSlot(walk: OpenWalk, frame: OpenFrame, slot: number): void {
|
|
133
|
+
const item = frame.level.items[slot];
|
|
134
|
+
|
|
135
|
+
if (item === undefined || !walk.ids.has(item.id) || walk.ancestors.has(item)) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const child = walk.index.childLevel(frame.level, slot);
|
|
140
|
+
|
|
141
|
+
if (child === undefined) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
walk.paths.add(child.path);
|
|
146
|
+
walk.ancestors.add(item);
|
|
147
|
+
walk.stack.push({ level: child, cursor: 0, owner: item });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function leaveOpenFrame(walk: OpenWalk, frame: OpenFrame): void {
|
|
151
|
+
if (frame.owner !== null) {
|
|
152
|
+
walk.ancestors.delete(frame.owner);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function expandedPathsFor(index: CollectionIndex, ids: Set<string>): Set<string> {
|
|
157
|
+
const paths: Set<string> = new Set();
|
|
158
|
+
|
|
159
|
+
if (ids.size === 0 || !index.isTree) {
|
|
160
|
+
return paths;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const frames = index.groups.map((level) => ({ level, cursor: 0, owner: null }));
|
|
164
|
+
const walk: OpenWalk = { index, ids, paths, stack: frames.toReversed(), ancestors: new Set() };
|
|
165
|
+
|
|
166
|
+
const onSlot = (frame: OpenFrame, slot: number): void => {
|
|
167
|
+
openSlot(walk, frame, slot);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const onLeave = (frame: OpenFrame): void => {
|
|
171
|
+
leaveOpenFrame(walk, frame);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
while (walk.stack.length > 0) {
|
|
175
|
+
advanceStack(walk.stack, onSlot, onLeave);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return paths;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildVisibleOrder(index: CollectionIndex, slots: SlotMap): VisibleOrder {
|
|
182
|
+
return walkVisible({ index, slots });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function findPositions(index: CollectionIndex, slots: SlotMap, ids: Set<string>): number[] {
|
|
186
|
+
const found: number[] = [];
|
|
187
|
+
|
|
188
|
+
walkVisible({
|
|
189
|
+
index,
|
|
190
|
+
slots,
|
|
191
|
+
visit: (row) => {
|
|
192
|
+
if (ids.has(row.item.id)) {
|
|
193
|
+
found.push(row.position);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return found;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export {
|
|
202
|
+
buildVisibleOrder,
|
|
203
|
+
expandedPathsFor,
|
|
204
|
+
findPositions,
|
|
205
|
+
walkVisible,
|
|
206
|
+
type VisibleOrder,
|
|
207
|
+
type WalkOptions,
|
|
208
|
+
};
|
|
@@ -23,13 +23,13 @@ type CollectionResult = {
|
|
|
23
23
|
|
|
24
24
|
function useCollectionData(options: CollectionDataOptions): Collection {
|
|
25
25
|
const { items, sections, isFlat } = options;
|
|
26
|
-
const [
|
|
26
|
+
const [collectionModel] = useState(createCollectionModel);
|
|
27
27
|
const index = useMemo(() => createCollectionIndex(items, sections, isFlat === true), [items, sections, isFlat]);
|
|
28
|
-
const collection = useMemo(() => createCollection(
|
|
28
|
+
const collection = useMemo(() => createCollection(collectionModel, index), [collectionModel, index]);
|
|
29
29
|
|
|
30
30
|
useLayoutEffect(() => {
|
|
31
|
-
|
|
32
|
-
}, [
|
|
31
|
+
collectionModel.sync(index);
|
|
32
|
+
}, [collectionModel, index]);
|
|
33
33
|
|
|
34
34
|
return collection;
|
|
35
35
|
}
|
|
@@ -54,4 +54,4 @@ function useCollection(options: CollectionOptions): CollectionResult {
|
|
|
54
54
|
return { collection, selection };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export { useCollection, useCollectionData
|
|
57
|
+
export { useCollection, useCollectionData };
|
package/src/list-view.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import { GtkListView, GtkSignalListItemFactory } from "@gtkx/jsx/gtk";
|
|
3
3
|
import { omit } from "@gtkx/utils";
|
|
4
4
|
import type { ListViewProps } from "./types.js";
|
|
5
|
-
import {
|
|
5
|
+
import { ItemPortals, useItemCells, useSectionHeader } from "./internal/cells.js";
|
|
6
6
|
import { useCollection } from "./internal/use-collection.js";
|
|
7
7
|
|
|
8
8
|
const LIST_VIEW_PROPS = [
|
|
@@ -15,6 +15,7 @@ const LIST_VIEW_PROPS = [
|
|
|
15
15
|
"selectionMode",
|
|
16
16
|
"expandedIds",
|
|
17
17
|
"onExpandedChange",
|
|
18
|
+
"expanderDescriptions",
|
|
18
19
|
"estimatedItemHeight",
|
|
19
20
|
"estimatedItemWidth",
|
|
20
21
|
] as const satisfies (keyof ListViewProps)[];
|
|
@@ -24,27 +25,30 @@ const LIST_VIEW_PROPS = [
|
|
|
24
25
|
* controlled selection, controlled tree expansion, and estimated item sizing.
|
|
25
26
|
*/
|
|
26
27
|
function ListView<T = unknown, S = unknown>(props: ListViewProps<T, S>): ReactNode {
|
|
27
|
-
const { renderItem, renderHeader, expandedIds,
|
|
28
|
+
const { renderItem, renderHeader, expandedIds, expanderDescriptions } = props;
|
|
29
|
+
const { estimatedItemHeight, estimatedItemWidth } = props;
|
|
28
30
|
const rest = omit(props, LIST_VIEW_PROPS);
|
|
29
31
|
const size = { width: estimatedItemWidth ?? -1, height: estimatedItemHeight ?? -1 };
|
|
30
32
|
const { collection, selection } = useCollection(props);
|
|
31
33
|
const itemCells = useItemCells(size);
|
|
32
|
-
const
|
|
34
|
+
const header = useSectionHeader(renderHeader, collection, size);
|
|
33
35
|
|
|
34
36
|
return (
|
|
35
37
|
<>
|
|
36
38
|
<GtkListView
|
|
37
39
|
model={selection}
|
|
38
40
|
factory={<GtkSignalListItemFactory {...itemCells.handlers} />}
|
|
39
|
-
{...
|
|
40
|
-
headerFactory: <GtkSignalListItemFactory {...headerCells.handlers} />,
|
|
41
|
-
})}
|
|
41
|
+
{...header.factoryProps}
|
|
42
42
|
{...rest}
|
|
43
43
|
/>
|
|
44
|
-
<ItemPortals
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
<ItemPortals
|
|
45
|
+
registry={itemCells}
|
|
46
|
+
render={renderItem}
|
|
47
|
+
collection={collection}
|
|
48
|
+
expandedIds={expandedIds}
|
|
49
|
+
expanderDescriptions={expanderDescriptions}
|
|
50
|
+
/>
|
|
51
|
+
{header.portals}
|
|
48
52
|
</>
|
|
49
53
|
);
|
|
50
54
|
}
|