@gtkx/components 2.0.0-beta.11 → 2.0.0-beta.13
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 +4 -4
- package/dist/column-view.d.ts.map +1 -1
- package/dist/column-view.js +8 -4
- package/dist/column-view.js.map +1 -1
- package/dist/combo-row.d.ts.map +1 -1
- package/dist/combo-row.js +3 -1
- package/dist/combo-row.js.map +1 -1
- package/dist/grid-view.d.ts.map +1 -1
- package/dist/grid-view.js +4 -3
- package/dist/grid-view.js.map +1 -1
- package/dist/internal/cells.d.ts +8 -9
- package/dist/internal/cells.d.ts.map +1 -1
- package/dist/internal/cells.js +98 -83
- package/dist/internal/cells.js.map +1 -1
- package/dist/internal/collection-index.d.ts +3 -5
- package/dist/internal/collection-index.d.ts.map +1 -1
- package/dist/internal/collection-index.js +3 -7
- package/dist/internal/collection-index.js.map +1 -1
- package/dist/internal/collection-model.d.ts +15 -4
- package/dist/internal/collection-model.d.ts.map +1 -1
- package/dist/internal/collection-model.js +154 -73
- package/dist/internal/collection-model.js.map +1 -1
- package/dist/internal/collection.d.ts +3 -3
- package/dist/internal/collection.d.ts.map +1 -1
- package/dist/internal/collection.js +11 -9
- package/dist/internal/collection.js.map +1 -1
- package/dist/internal/controlled-sync.d.ts +2 -1
- package/dist/internal/controlled-sync.d.ts.map +1 -1
- package/dist/internal/controlled-sync.js +6 -1
- package/dist/internal/controlled-sync.js.map +1 -1
- package/dist/internal/drop-down.d.ts +10 -4
- package/dist/internal/drop-down.d.ts.map +1 -1
- package/dist/internal/drop-down.js +61 -9
- package/dist/internal/drop-down.js.map +1 -1
- package/dist/internal/expansion.d.ts.map +1 -1
- package/dist/internal/expansion.js +46 -19
- package/dist/internal/expansion.js.map +1 -1
- package/dist/internal/first-header.d.ts +11 -0
- package/dist/internal/first-header.d.ts.map +1 -0
- package/dist/internal/first-header.js +21 -0
- package/dist/internal/first-header.js.map +1 -0
- package/dist/internal/keys.d.ts.map +1 -1
- package/dist/internal/level-splice.d.ts +27 -0
- package/dist/internal/level-splice.d.ts.map +1 -0
- package/dist/internal/level-splice.js +98 -0
- package/dist/internal/level-splice.js.map +1 -0
- package/dist/internal/selection.d.ts.map +1 -1
- package/dist/internal/selection.js +23 -16
- package/dist/internal/selection.js.map +1 -1
- package/dist/internal/tree-expansion.d.ts +3 -1
- package/dist/internal/tree-expansion.d.ts.map +1 -1
- package/dist/internal/tree-expansion.js +31 -2
- package/dist/internal/tree-expansion.js.map +1 -1
- package/dist/internal/tree-order.d.ts +2 -1
- package/dist/internal/tree-order.d.ts.map +1 -1
- package/dist/internal/tree-order.js +3 -2
- package/dist/internal/tree-order.js.map +1 -1
- package/dist/internal/use-collection.d.ts +1 -0
- package/dist/internal/use-collection.d.ts.map +1 -1
- package/dist/internal/use-collection.js +6 -2
- package/dist/internal/use-collection.js.map +1 -1
- package/dist/internal/use-widget-ref.d.ts.map +1 -1
- package/dist/list-item-factory.d.ts +1 -1
- package/dist/list-item-factory.d.ts.map +1 -1
- package/dist/list-item-factory.js +3 -3
- package/dist/list-item-factory.js.map +1 -1
- package/dist/list-view.d.ts.map +1 -1
- package/dist/list-view.js +9 -4
- package/dist/list-view.js.map +1 -1
- package/dist/types.d.ts +35 -12
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +10 -20
- package/src/column-view.tsx +8 -4
- package/src/combo-row.tsx +4 -1
- package/src/grid-view.tsx +3 -2
- package/src/internal/cells.tsx +174 -151
- package/src/internal/collection-index.ts +7 -14
- package/src/internal/collection-model.ts +211 -76
- package/src/internal/collection.ts +14 -13
- package/src/internal/controlled-sync.ts +8 -1
- package/src/internal/drop-down.tsx +112 -19
- package/src/internal/expansion.ts +52 -20
- package/src/internal/first-header.ts +23 -0
- package/src/internal/level-splice.ts +157 -0
- package/src/internal/selection.tsx +24 -19
- package/src/internal/tree-expansion.ts +43 -1
- package/src/internal/tree-order.ts +4 -10
- package/src/internal/use-collection.tsx +8 -2
- package/src/list-item-factory.tsx +2 -1
- package/src/list-view.tsx +10 -2
- package/src/types.ts +54 -24
|
@@ -1,16 +1,30 @@
|
|
|
1
|
-
import type * as
|
|
1
|
+
import type * as Adw from "@gtkx/gi/adw";
|
|
2
2
|
import type { ElementType, ReactNode, Ref } from "react";
|
|
3
|
-
import
|
|
3
|
+
import * as GObject from "@gtkx/gi/gobject";
|
|
4
|
+
import * as Gtk from "@gtkx/gi/gtk";
|
|
5
|
+
import type * as Pango from "@gtkx/gi/pango";
|
|
6
|
+
import { GtkBox, GtkImage, GtkLabel, GtkSignalListItemFactory } from "@gtkx/jsx/gtk";
|
|
7
|
+
import { useProperty } from "@gtkx/react";
|
|
8
|
+
import { useLatestRef } from "@gtkx/react/internal";
|
|
4
9
|
import { omit, type Primitive } from "@gtkx/utils";
|
|
5
|
-
import { useRef } from "react";
|
|
6
|
-
import type { DropDownOwnProps,
|
|
10
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
11
|
+
import type { DropDownOwnProps, ListItemRenderer } from "../types.js";
|
|
7
12
|
import { ItemPortals, useItemCells, useSectionHeader } from "./cells.js";
|
|
13
|
+
import { type SlotRef, slotRefFor } from "./collection-model.js";
|
|
8
14
|
import { type Collection, isCollectionIdle } from "./collection.js";
|
|
9
15
|
import { useControlledSync } from "./controlled-sync.js";
|
|
16
|
+
import { showFirstHeader } from "./first-header.js";
|
|
10
17
|
import { useCollectionData } from "./use-collection.js";
|
|
11
18
|
import { useWidgetRef } from "./use-widget-ref.js";
|
|
12
19
|
|
|
13
|
-
type SelectableWidget =
|
|
20
|
+
type SelectableWidget = Gtk.DropDown | Adw.ComboRow;
|
|
21
|
+
|
|
22
|
+
/** Label settings of the widget's own default item factory, kept by the default renderers. */
|
|
23
|
+
type ItemLabelProps = {
|
|
24
|
+
ellipsize?: Pango.EllipsizeMode;
|
|
25
|
+
maxWidthChars?: number;
|
|
26
|
+
widthChars?: number;
|
|
27
|
+
};
|
|
14
28
|
|
|
15
29
|
type DropDownBaseProps = DropDownOwnProps<unknown, unknown> & {
|
|
16
30
|
onNotifySelected?: ((value: number | null, self: SelectableWidget) => void) | null | undefined;
|
|
@@ -25,6 +39,7 @@ type SelectionOptions = {
|
|
|
25
39
|
|
|
26
40
|
type NotifySelectedHandler = NonNullable<DropDownBaseProps["onNotifySelected"]>;
|
|
27
41
|
type ApplyState = { isApplying: boolean; isReady: boolean };
|
|
42
|
+
type ShownSelection = { ref: SlotRef | null; text: string };
|
|
28
43
|
|
|
29
44
|
type NotifyContext = {
|
|
30
45
|
options: SelectionOptions;
|
|
@@ -40,6 +55,7 @@ type KnownSelection = {
|
|
|
40
55
|
|
|
41
56
|
const DROP_DOWN_PROPS: string[] = [
|
|
42
57
|
"component",
|
|
58
|
+
"itemLabel",
|
|
43
59
|
"items",
|
|
44
60
|
"sections",
|
|
45
61
|
"selectedId",
|
|
@@ -55,15 +71,84 @@ function newApplyState(): ApplyState {
|
|
|
55
71
|
return { isApplying: false, isReady: false };
|
|
56
72
|
}
|
|
57
73
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
74
|
+
const defaultRenderer =
|
|
75
|
+
(itemLabel: ItemLabelProps): ListItemRenderer<Primitive> =>
|
|
76
|
+
({ item }) =>
|
|
77
|
+
item == null ? null : <GtkLabel {...itemLabel}>{String(item)}</GtkLabel>;
|
|
78
|
+
const faceRenderer = (props: DropDownBaseProps, itemLabel: ItemLabelProps): ListItemRenderer<never> =>
|
|
79
|
+
props.renderItem ?? defaultRenderer(itemLabel);
|
|
80
|
+
|
|
81
|
+
const selectionMarkedRenderer =
|
|
82
|
+
(selected: number | undefined, itemLabel: ItemLabelProps): ListItemRenderer<Primitive> =>
|
|
83
|
+
({ item, index }) => (
|
|
84
|
+
<GtkBox>
|
|
85
|
+
<GtkLabel xalign={0} {...itemLabel}>
|
|
86
|
+
{item == null ? "" : String(item)}
|
|
87
|
+
</GtkLabel>
|
|
88
|
+
<GtkImage
|
|
89
|
+
iconName="object-select-symbolic"
|
|
90
|
+
accessibleRole={Gtk.AccessibleRole.PRESENTATION}
|
|
91
|
+
opacity={index === selected ? 1 : 0}
|
|
92
|
+
/>
|
|
93
|
+
</GtkBox>
|
|
94
|
+
);
|
|
61
95
|
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
):
|
|
96
|
+
const popupRenderer = (
|
|
97
|
+
props: DropDownBaseProps,
|
|
98
|
+
selected: number | undefined,
|
|
99
|
+
itemLabel: ItemLabelProps,
|
|
100
|
+
): ListItemRenderer<never> | null =>
|
|
101
|
+
props.renderListItem ?? (props.renderItem == null ? selectionMarkedRenderer(selected, itemLabel) : null);
|
|
102
|
+
|
|
103
|
+
const textOf = (value: unknown): string => {
|
|
104
|
+
switch (typeof value) {
|
|
105
|
+
case "string":
|
|
106
|
+
return value;
|
|
107
|
+
case "number":
|
|
108
|
+
case "bigint":
|
|
109
|
+
case "boolean":
|
|
110
|
+
return String(value);
|
|
111
|
+
case "symbol":
|
|
112
|
+
return value.toString();
|
|
113
|
+
default:
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const slotText = (collection: Collection, ref: SlotRef | null): string =>
|
|
119
|
+
ref === null ? "" : textOf(collection.itemAt(ref)?.value);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* AdwComboRow reads the selected item's text for its subtitle only when the selected item or the expression changes.
|
|
123
|
+
* Slots keep their objects when an item's value changes in place, so the expression is replaced when the selected
|
|
124
|
+
* item's text changes without a selection change.
|
|
125
|
+
*/
|
|
126
|
+
const useItemTextExpression = (widget: SelectableWidget | null, collection: Collection): Gtk.Expression => {
|
|
127
|
+
const latest = useLatestRef(collection);
|
|
128
|
+
const shown = useRef<ShownSelection>({ ref: null, text: "" });
|
|
129
|
+
const createExpression = (): Gtk.Expression =>
|
|
130
|
+
Gtk.ClosureExpression.new(
|
|
131
|
+
GObject.TYPE_STRING,
|
|
132
|
+
(object: GObject.Object | null) => slotText(latest.current, slotRefFor(object)),
|
|
133
|
+
null,
|
|
134
|
+
);
|
|
135
|
+
const [expression, setExpression] = useState(createExpression);
|
|
136
|
+
|
|
137
|
+
useLayoutEffect(() => {
|
|
138
|
+
const ref = slotRefFor(widget?.getSelectedItem() ?? null);
|
|
139
|
+
const text = slotText(collection, ref);
|
|
140
|
+
const isRenamed = ref !== null && ref === shown.current.ref && text !== shown.current.text;
|
|
141
|
+
shown.current = { ref, text };
|
|
142
|
+
|
|
143
|
+
if (isRenamed) {
|
|
144
|
+
setExpression(createExpression());
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return expression;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const resolvePosition = (widget: SelectableWidget, collection: Collection, selectedId: string | undefined): number => {
|
|
67
152
|
if (selectedId === undefined) {
|
|
68
153
|
return widget.getSelected();
|
|
69
154
|
}
|
|
@@ -179,31 +264,39 @@ const useDropDownSelection = (options: SelectionOptions): NotifySelectedHandler
|
|
|
179
264
|
};
|
|
180
265
|
};
|
|
181
266
|
|
|
182
|
-
function DropDownBase(props: DropDownBaseProps & { component: ElementType }): ReactNode {
|
|
183
|
-
const { component: Component, items, sections,
|
|
267
|
+
function DropDownBase(props: DropDownBaseProps & { component: ElementType; itemLabel?: ItemLabelProps }): ReactNode {
|
|
268
|
+
const { component: Component, itemLabel = {}, items, sections, renderHeader, ref } = props;
|
|
184
269
|
const rest = omit(props, DROP_DOWN_PROPS);
|
|
185
270
|
const [widget, refCallback] = useWidgetRef<SelectableWidget>(ref);
|
|
186
|
-
const { collection, model } = useCollectionData({
|
|
271
|
+
const { collection, model } = useCollectionData({
|
|
272
|
+
items,
|
|
273
|
+
sections,
|
|
274
|
+
isFlat: true,
|
|
275
|
+
onSectionsFilled: () => showFirstHeader(widget),
|
|
276
|
+
});
|
|
187
277
|
const faceCells = useItemCells({ width: -1, height: -1 });
|
|
188
278
|
const listCells = useItemCells({ width: -1, height: -1 });
|
|
189
279
|
const header = useSectionHeader(renderHeader, collection, { width: -1, height: -1 });
|
|
190
280
|
const handleNotifySelected = useDropDownSelection({ widget, collection, props });
|
|
281
|
+
const renderListItem = popupRenderer(props, useProperty(widget, "selected"), itemLabel);
|
|
282
|
+
const expression = useItemTextExpression(widget, collection);
|
|
191
283
|
|
|
192
284
|
return (
|
|
193
285
|
<>
|
|
194
286
|
<Component
|
|
195
287
|
ref={refCallback}
|
|
196
288
|
model={model}
|
|
289
|
+
expression={expression}
|
|
197
290
|
factory={<GtkSignalListItemFactory {...faceCells.handlers} />}
|
|
198
|
-
{...(renderListItem
|
|
291
|
+
{...(renderListItem !== null && {
|
|
199
292
|
listFactory: <GtkSignalListItemFactory {...listCells.handlers} />,
|
|
200
293
|
})}
|
|
201
294
|
{...header.factoryProps}
|
|
202
295
|
{...rest}
|
|
203
296
|
onNotifySelected={handleNotifySelected}
|
|
204
297
|
/>
|
|
205
|
-
<ItemPortals registry={faceCells} render={faceRenderer(props)} collection={collection} />
|
|
206
|
-
{renderListItem
|
|
298
|
+
<ItemPortals registry={faceCells} render={faceRenderer(props, itemLabel)} collection={collection} />
|
|
299
|
+
{renderListItem !== null && (
|
|
207
300
|
<ItemPortals registry={listCells} render={renderListItem} collection={collection} />
|
|
208
301
|
)}
|
|
209
302
|
{header.portals}
|
|
@@ -3,7 +3,7 @@ import type { Collection } from "./collection.js";
|
|
|
3
3
|
import type { TreeExpansion } from "./tree-expansion.js";
|
|
4
4
|
import type { VisibleOrder, WalkOptions } from "./tree-order.js";
|
|
5
5
|
import { isCollectionIdle } from "./collection.js";
|
|
6
|
-
import { useControlledSync } from "./controlled-sync.js";
|
|
6
|
+
import { hasSameIds, useControlledSync } from "./controlled-sync.js";
|
|
7
7
|
import { joinParts } from "./keys.js";
|
|
8
8
|
import { trackPaths } from "./slots.js";
|
|
9
9
|
import { adoptOrder, markExpanded, orderFor } from "./tree-expansion.js";
|
|
@@ -34,29 +34,44 @@ function hasSameExpansion(expanded: Set<string>, wanted: Set<string>): boolean {
|
|
|
34
34
|
return expanded.size === wanted.size && wanted.isSubsetOf(expanded);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function
|
|
37
|
+
function collapsingPositions(expansion: TreeExpansion, wanted: Set<string>): number[] {
|
|
38
|
+
const positions: number[] = [];
|
|
39
|
+
|
|
40
|
+
walkVisible({
|
|
41
|
+
index: expansion.index,
|
|
42
|
+
slots: expansion.slots,
|
|
43
|
+
marks: trackPaths(expansion.expanded.difference(wanted)),
|
|
44
|
+
visit: (row) => {
|
|
45
|
+
if (row.isMarked) {
|
|
46
|
+
positions.push(row.position);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return positions;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function expandOptions(collection: Collection, wanted: Set<string>): WalkOptions {
|
|
38
55
|
const { expansion } = collection;
|
|
39
56
|
|
|
40
57
|
return {
|
|
41
58
|
index: expansion.index,
|
|
42
59
|
slots: trackPaths(wanted),
|
|
43
|
-
marks: trackPaths(wanted.
|
|
60
|
+
marks: trackPaths(wanted.difference(expansion.expanded)),
|
|
44
61
|
visit: (row) => {
|
|
45
62
|
if (row.isMarked) {
|
|
46
|
-
collection.rowAt(row.position)?.setExpanded(
|
|
63
|
+
collection.rowAt(row.position)?.setExpanded(true);
|
|
47
64
|
}
|
|
48
65
|
},
|
|
49
66
|
};
|
|
50
67
|
}
|
|
51
68
|
|
|
52
|
-
function
|
|
53
|
-
expansion.
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
return walkVisible(options);
|
|
57
|
-
} finally {
|
|
58
|
-
expansion.isApplying = false;
|
|
69
|
+
function toggleRows(collection: Collection, wanted: Set<string>): VisibleOrder {
|
|
70
|
+
for (const position of collapsingPositions(collection.expansion, wanted).toReversed()) {
|
|
71
|
+
collection.rowAt(position)?.setExpanded(false);
|
|
59
72
|
}
|
|
73
|
+
|
|
74
|
+
return walkVisible(expandOptions(collection, wanted));
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
function applyWanted(collection: Collection, expandedIds: string[]): void {
|
|
@@ -67,19 +82,29 @@ function applyWanted(collection: Collection, expandedIds: string[]): void {
|
|
|
67
82
|
return;
|
|
68
83
|
}
|
|
69
84
|
|
|
70
|
-
|
|
85
|
+
expansion.isApplying = true;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
adoptOrder(expansion, toggleRows(collection, wanted));
|
|
89
|
+
} finally {
|
|
90
|
+
expansion.isApplying = false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function rememberExpansion(context: ExpansionContext, expandedPaths: string[]): boolean {
|
|
95
|
+
const key = joinParts(expandedPaths);
|
|
96
|
+
const isKnown = context.last.key === key;
|
|
97
|
+
context.last.key = key;
|
|
98
|
+
|
|
99
|
+
return isKnown;
|
|
71
100
|
}
|
|
72
101
|
|
|
73
102
|
function reportExpansion(context: ExpansionContext): void {
|
|
74
103
|
const { expandedIds, expandedPaths } = orderFor(context.collection.expansion);
|
|
75
|
-
const key = joinParts(expandedPaths);
|
|
76
104
|
|
|
77
|
-
if (context
|
|
78
|
-
|
|
105
|
+
if (!rememberExpansion(context, expandedPaths)) {
|
|
106
|
+
context.onExpandedChange?.([...expandedIds]);
|
|
79
107
|
}
|
|
80
|
-
|
|
81
|
-
context.last.key = key;
|
|
82
|
-
context.onExpandedChange?.([...expandedIds]);
|
|
83
108
|
}
|
|
84
109
|
|
|
85
110
|
function applyControlledExpansion(context: ExpansionContext, expandedIds: string[] | null | undefined): void {
|
|
@@ -87,8 +112,15 @@ function applyControlledExpansion(context: ExpansionContext, expandedIds: string
|
|
|
87
112
|
return;
|
|
88
113
|
}
|
|
89
114
|
|
|
90
|
-
|
|
91
|
-
|
|
115
|
+
const requested = expandedIds ?? [];
|
|
116
|
+
applyWanted(context.collection, requested);
|
|
117
|
+
const order = orderFor(context.collection.expansion);
|
|
118
|
+
|
|
119
|
+
if (hasSameIds(order.expandedIds, requested)) {
|
|
120
|
+
rememberExpansion(context, order.expandedPaths);
|
|
121
|
+
} else {
|
|
122
|
+
reportExpansion(context);
|
|
123
|
+
}
|
|
92
124
|
}
|
|
93
125
|
|
|
94
126
|
function didRowDrift(collection: Collection, change: ItemsChange): boolean {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type * as Adw from "@gtkx/gi/adw";
|
|
2
|
+
import * as Gtk from "@gtkx/gi/gtk";
|
|
3
|
+
|
|
4
|
+
type SectionedWidget = Gtk.ListView | Gtk.ColumnView | Gtk.DropDown | Adw.ComboRow;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* TODO: GTK 4.22 anchors a list whose model fills after the list holds it at the first row, scrolled past that
|
|
8
|
+
* row's section header. Scrolling a view to its first row, or handing a drop-down its filled model again, starts
|
|
9
|
+
* it at the header. Remove once a GTK release fixes the anchor and GTKX requires it.
|
|
10
|
+
*/
|
|
11
|
+
function showFirstHeader(widget: SectionedWidget | null): void {
|
|
12
|
+
if (widget instanceof Gtk.ListView) {
|
|
13
|
+
widget.scrollTo(0, Gtk.ListScrollFlags.NONE, null);
|
|
14
|
+
} else if (widget instanceof Gtk.ColumnView) {
|
|
15
|
+
widget.scrollTo(0, null, Gtk.ListScrollFlags.NONE, null);
|
|
16
|
+
} else if (widget !== null) {
|
|
17
|
+
const model = widget.getModel();
|
|
18
|
+
widget.setModel(null);
|
|
19
|
+
widget.setModel(model);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { showFirstHeader };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { joinParts } from "./keys.js";
|
|
2
|
+
|
|
3
|
+
type Splice = {
|
|
4
|
+
start: number;
|
|
5
|
+
removed: number;
|
|
6
|
+
added: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type KeyAt = (slot: number) => string;
|
|
10
|
+
|
|
11
|
+
type LevelKeys = {
|
|
12
|
+
previousLength: number;
|
|
13
|
+
nextLength: number;
|
|
14
|
+
previousKey: KeyAt;
|
|
15
|
+
nextKey: KeyAt;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type Relocate = (slot: number) => number | undefined;
|
|
19
|
+
|
|
20
|
+
const NO_MOVES: Map<number, number> = new Map();
|
|
21
|
+
const NO_SPLICE: Splice = { start: 0, removed: 0, added: 0 };
|
|
22
|
+
const MOVE_SEARCH_LIMIT = 1024;
|
|
23
|
+
|
|
24
|
+
function findSplice(keys: LevelKeys): Splice {
|
|
25
|
+
const { previousLength, nextLength, previousKey, nextKey } = keys;
|
|
26
|
+
const overlap = Math.min(previousLength, nextLength);
|
|
27
|
+
let start = 0;
|
|
28
|
+
|
|
29
|
+
while (start < overlap && previousKey(start) === nextKey(start)) {
|
|
30
|
+
start += 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let shared = 0;
|
|
34
|
+
|
|
35
|
+
while (shared < overlap - start && previousKey(previousLength - 1 - shared) === nextKey(nextLength - 1 - shared)) {
|
|
36
|
+
shared += 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { start, removed: previousLength - start - shared, added: nextLength - start - shared };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasSplice(splice: Splice): boolean {
|
|
43
|
+
return splice.removed > 0 || splice.added > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isRemoved(splice: Splice, slot: number): boolean {
|
|
47
|
+
return slot >= splice.start && slot < splice.start + splice.removed;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function occurrenceKeys(key: KeyAt, from: number, length: number, wanted: Set<string>): Map<number, string> {
|
|
51
|
+
const counts: Map<string, number> = new Map();
|
|
52
|
+
const keys: Map<number, string> = new Map();
|
|
53
|
+
|
|
54
|
+
for (let slot = from; slot < from + length; slot++) {
|
|
55
|
+
const id = key(slot);
|
|
56
|
+
|
|
57
|
+
if (!wanted.has(id)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const occurrence = counts.get(id) ?? 0;
|
|
62
|
+
counts.set(id, occurrence + 1);
|
|
63
|
+
keys.set(slot, joinParts([id, String(occurrence)]));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return keys;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function matchMoves(keys: LevelKeys, splice: Splice, candidates: Iterable<number>): Map<number, number> {
|
|
70
|
+
const removed = Iterator.from(candidates)
|
|
71
|
+
.filter((slot) => isRemoved(splice, slot))
|
|
72
|
+
.toArray();
|
|
73
|
+
|
|
74
|
+
if (removed.length === 0 || splice.added === 0) {
|
|
75
|
+
return NO_MOVES;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const wanted = new Set(removed.map((slot) => keys.previousKey(slot)));
|
|
79
|
+
const previous = occurrenceKeys(keys.previousKey, splice.start, splice.removed, wanted);
|
|
80
|
+
const bySlotKey: Map<string, number> = new Map();
|
|
81
|
+
|
|
82
|
+
for (const slot of removed) {
|
|
83
|
+
const key = previous.get(slot);
|
|
84
|
+
|
|
85
|
+
if (key !== undefined) {
|
|
86
|
+
bySlotKey.set(key, slot);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const moves: Map<number, number> = new Map();
|
|
91
|
+
|
|
92
|
+
for (const [slot, key] of occurrenceKeys(keys.nextKey, splice.start, splice.added, wanted)) {
|
|
93
|
+
const origin = bySlotKey.get(key);
|
|
94
|
+
|
|
95
|
+
if (origin !== undefined) {
|
|
96
|
+
moves.set(origin, slot);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return moves;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function relocateSlot(splice: Splice, slot: number, moves: Map<number, number> = NO_MOVES): number | undefined {
|
|
104
|
+
if (slot < splice.start) {
|
|
105
|
+
return slot;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (isRemoved(splice, slot)) {
|
|
109
|
+
return moves.get(slot);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return slot + splice.added - splice.removed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function relocateEntries<V>(entries: Map<number, V>, relocate: Relocate, onDrop: (value: V) => void): Map<number, V> {
|
|
116
|
+
const relocated: Map<number, V> = new Map();
|
|
117
|
+
|
|
118
|
+
for (const [slot, value] of entries) {
|
|
119
|
+
const next = relocate(slot);
|
|
120
|
+
|
|
121
|
+
if (next === undefined) {
|
|
122
|
+
onDrop(value);
|
|
123
|
+
} else {
|
|
124
|
+
relocated.set(next, value);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return relocated;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* GTK looks up every added item for a moved row whenever a splice removes a row it tracks, such as the scroll
|
|
133
|
+
* anchor or the focus, which makes every lazily created item real. Past a limit, the overlapping slots are
|
|
134
|
+
* rewritten in place instead and only the difference is spliced, so rows that move there remount.
|
|
135
|
+
*/
|
|
136
|
+
function searchableSplice(splice: Splice, addedRows: number): Splice {
|
|
137
|
+
if (addedRows <= MOVE_SEARCH_LIMIT) {
|
|
138
|
+
return splice;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const rewritten = Math.min(splice.removed, splice.added);
|
|
142
|
+
|
|
143
|
+
return { start: splice.start + rewritten, removed: splice.removed - rewritten, added: splice.added - rewritten };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export {
|
|
147
|
+
NO_SPLICE,
|
|
148
|
+
findSplice,
|
|
149
|
+
hasSplice,
|
|
150
|
+
matchMoves,
|
|
151
|
+
relocateEntries,
|
|
152
|
+
relocateSlot,
|
|
153
|
+
searchableSplice,
|
|
154
|
+
type LevelKeys,
|
|
155
|
+
type Relocate,
|
|
156
|
+
type Splice,
|
|
157
|
+
};
|
|
@@ -6,7 +6,7 @@ import type { Collection } from "./collection.js";
|
|
|
6
6
|
import type { ItemsChangeHandler } from "./expansion.js";
|
|
7
7
|
import type { MatchedRows } from "./tree-order.js";
|
|
8
8
|
import { isCollectionIdle } from "./collection.js";
|
|
9
|
-
import { useControlledSync } from "./controlled-sync.js";
|
|
9
|
+
import { hasSameIds, useControlledSync } from "./controlled-sync.js";
|
|
10
10
|
import { joinParts } from "./keys.js";
|
|
11
11
|
|
|
12
12
|
type SelectionOptions = {
|
|
@@ -89,10 +89,6 @@ function plannedSelection(context: SelectionContext, ids: string[]): MatchedRows
|
|
|
89
89
|
|
|
90
90
|
const rows = collection.rowsFor(ids);
|
|
91
91
|
|
|
92
|
-
if (ids.length > 0 && rows.positions.length === 0) {
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
92
|
if (selection instanceof Gtk.SingleSelection) {
|
|
97
93
|
return { positions: rows.positions.slice(0, 1), ids: rows.ids.slice(0, 1) };
|
|
98
94
|
}
|
|
@@ -150,16 +146,27 @@ function applySelection(selection: Gtk.SelectionModel, positions: number[]): Gtk
|
|
|
150
146
|
return selected;
|
|
151
147
|
}
|
|
152
148
|
|
|
153
|
-
function
|
|
149
|
+
function rememberSelection(context: SelectionContext, ids: string[]): boolean {
|
|
154
150
|
const key = joinParts(ids);
|
|
151
|
+
const isKnown = context.last.selection === context.selection && context.last.key === key;
|
|
152
|
+
context.last.selection = context.selection;
|
|
153
|
+
context.last.key = key;
|
|
155
154
|
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
return isKnown;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function reportSelection(context: SelectionContext, ids: string[]): void {
|
|
159
|
+
if (!rememberSelection(context, ids)) {
|
|
160
|
+
context.onSelectionChanged?.(ids);
|
|
158
161
|
}
|
|
162
|
+
}
|
|
159
163
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
function settleSelection(context: SelectionContext, ids: string[], selectedIds: string[]): void {
|
|
165
|
+
if (hasSameIds(ids, selectedIds)) {
|
|
166
|
+
rememberSelection(context, ids);
|
|
167
|
+
} else {
|
|
168
|
+
reportSelection(context, ids);
|
|
169
|
+
}
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
function hasSelectionDrifted(actual: number[], planned: number[]): boolean {
|
|
@@ -198,15 +205,16 @@ function applyControlledSelection(context: SelectionContext, selectedIds: string
|
|
|
198
205
|
return;
|
|
199
206
|
}
|
|
200
207
|
|
|
201
|
-
const
|
|
208
|
+
const requested = selectedIds ?? [];
|
|
209
|
+
const planned = plannedSelection(context, requested);
|
|
202
210
|
|
|
203
211
|
if (planned !== null && didApplyPlanned(context, selection, planned.positions)) {
|
|
204
|
-
|
|
212
|
+
settleSelection(context, planned.ids, requested);
|
|
205
213
|
|
|
206
214
|
return;
|
|
207
215
|
}
|
|
208
216
|
|
|
209
|
-
|
|
217
|
+
settleSelection(context, collection.idsAt(selectedPositions(selection)), requested);
|
|
210
218
|
}
|
|
211
219
|
|
|
212
220
|
function reportDrift(
|
|
@@ -229,16 +237,13 @@ function observeSelection(
|
|
|
229
237
|
): void {
|
|
230
238
|
const { selection, collection, state } = context;
|
|
231
239
|
|
|
232
|
-
if (selection === null || state.isApplying) {
|
|
240
|
+
if (selection === null || state.isApplying || !isCollectionIdle(collection)) {
|
|
233
241
|
return;
|
|
234
242
|
}
|
|
235
243
|
|
|
236
244
|
const positions = selectedPositions(selection);
|
|
237
245
|
reportSelection(context, collection.idsAt(positions));
|
|
238
|
-
|
|
239
|
-
if (isCollectionIdle(collection)) {
|
|
240
|
-
reportDrift(context, selectedIds, positions, onDrift);
|
|
241
|
-
}
|
|
246
|
+
reportDrift(context, selectedIds, positions, onDrift);
|
|
242
247
|
}
|
|
243
248
|
|
|
244
249
|
function newLastSelection(): LastSelection {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { CollectionIndex } from "./collection-index.js";
|
|
2
2
|
import type { SlotMap } from "./slots.js";
|
|
3
3
|
import type { VisibleOrder } from "./tree-order.js";
|
|
4
|
-
import {
|
|
4
|
+
import type { Relocate } from "./level-splice.js";
|
|
5
|
+
import { decodePartAt, encodePart } from "./keys.js";
|
|
5
6
|
import { getSlotKey, trackPath, trackPaths } from "./slots.js";
|
|
6
7
|
import { walkVisible } from "./tree-order.js";
|
|
7
8
|
|
|
@@ -75,6 +76,46 @@ function pruneSlots(expansion: TreeExpansion, levelPath: string, isPruned: (slot
|
|
|
75
76
|
expansion.order = null;
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
function relocatePath(path: string, levelPath: string, relocate: Relocate): string | null {
|
|
80
|
+
if (path.length === levelPath.length || !path.startsWith(levelPath)) {
|
|
81
|
+
return path;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const part = decodePartAt(path, levelPath.length);
|
|
85
|
+
|
|
86
|
+
if (part === null) {
|
|
87
|
+
return path;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const slot = relocate(Number(part));
|
|
91
|
+
|
|
92
|
+
if (slot === undefined) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return levelPath + encodePart(String(slot)) + path.slice(levelPath.length + encodePart(part).length);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function relocateSlots(expansion: TreeExpansion, levelPath: string, relocate: Relocate): void {
|
|
100
|
+
if (expansion.expanded.size === 0) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const expanded: Set<string> = new Set();
|
|
105
|
+
|
|
106
|
+
for (const path of expansion.expanded) {
|
|
107
|
+
const relocated = relocatePath(path, levelPath, relocate);
|
|
108
|
+
|
|
109
|
+
if (relocated !== null) {
|
|
110
|
+
expanded.add(relocated);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
expansion.expanded = expanded;
|
|
115
|
+
expansion.slots = trackPaths(expanded);
|
|
116
|
+
expansion.order = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
78
119
|
function markExpanded(expansion: TreeExpansion, path: string, isExpanded: boolean): void {
|
|
79
120
|
if (isExpanded) {
|
|
80
121
|
expansion.expanded.add(path);
|
|
@@ -99,5 +140,6 @@ export {
|
|
|
99
140
|
markExpanded,
|
|
100
141
|
orderFor,
|
|
101
142
|
pruneSlots,
|
|
143
|
+
relocateSlots,
|
|
102
144
|
type TreeExpansion,
|
|
103
145
|
};
|
|
@@ -12,6 +12,7 @@ type VisibleRow = {
|
|
|
12
12
|
type VisibleOrder = {
|
|
13
13
|
expandedPaths: string[];
|
|
14
14
|
expandedIds: string[];
|
|
15
|
+
positions: Map<string, number>;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
type MatchedRows = {
|
|
@@ -39,6 +40,7 @@ function walkLevel(options: WalkOptions, level: Level, state: WalkState): void {
|
|
|
39
40
|
|
|
40
41
|
for (const [slot, item] of level.items.entries()) {
|
|
41
42
|
const isOpen = open?.has(slot) ?? false;
|
|
43
|
+
state.order.positions.set(item.id, state.position);
|
|
42
44
|
options.visit?.({ item, position: state.position, isOpen, isMarked: marked?.has(slot) ?? false });
|
|
43
45
|
state.position += 1;
|
|
44
46
|
|
|
@@ -57,7 +59,7 @@ function walkLevel(options: WalkOptions, level: Level, state: WalkState): void {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
function walkVisible(options: WalkOptions): VisibleOrder {
|
|
60
|
-
const state: WalkState = { position: 0, order: { expandedPaths: [], expandedIds: [] } };
|
|
62
|
+
const state: WalkState = { position: 0, order: { expandedPaths: [], expandedIds: [], positions: new Map() } };
|
|
61
63
|
|
|
62
64
|
for (const level of options.index.groups) {
|
|
63
65
|
walkLevel(options, level, state);
|
|
@@ -133,12 +135,4 @@ function findIds(index: CollectionIndex, slots: SlotMap, positions: Set<number>)
|
|
|
133
135
|
return found;
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
export {
|
|
137
|
-
expandedPathsFor,
|
|
138
|
-
findIds,
|
|
139
|
-
findRows,
|
|
140
|
-
walkVisible,
|
|
141
|
-
type MatchedRows,
|
|
142
|
-
type VisibleOrder,
|
|
143
|
-
type WalkOptions,
|
|
144
|
-
};
|
|
138
|
+
export { expandedPathsFor, findIds, findRows, walkVisible, type MatchedRows, type VisibleOrder, type WalkOptions };
|