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