@gtkx/components 0.21.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/dist/collection-view.d.ts +30 -0
- package/dist/collection-view.d.ts.map +1 -0
- package/dist/collection-view.js +52 -0
- package/dist/collection-view.js.map +1 -0
- package/dist/column-view-column.d.ts +17 -0
- package/dist/column-view-column.d.ts.map +1 -0
- package/dist/column-view-column.js +51 -0
- package/dist/column-view-column.js.map +1 -0
- package/dist/column-view.d.ts +18 -0
- package/dist/column-view.d.ts.map +1 -0
- package/dist/column-view.js +108 -0
- package/dist/column-view.js.map +1 -0
- package/dist/constraint-layout-apply.d.ts +6 -0
- package/dist/constraint-layout-apply.d.ts.map +1 -0
- package/dist/constraint-layout-apply.js +107 -0
- package/dist/constraint-layout-apply.js.map +1 -0
- package/dist/constraint-layout.d.ts +13 -0
- package/dist/constraint-layout.d.ts.map +1 -0
- package/dist/constraint-layout.js +85 -0
- package/dist/constraint-layout.js.map +1 -0
- package/dist/contexts/column-view-context.d.ts +17 -0
- package/dist/contexts/column-view-context.d.ts.map +1 -0
- package/dist/contexts/column-view-context.js +10 -0
- package/dist/contexts/column-view-context.js.map +1 -0
- package/dist/drop-down.d.ts +41 -0
- package/dist/drop-down.d.ts.map +1 -0
- package/dist/drop-down.js +129 -0
- package/dist/drop-down.js.map +1 -0
- package/dist/grid-view.d.ts +16 -0
- package/dist/grid-view.d.ts.map +1 -0
- package/dist/grid-view.js +22 -0
- package/dist/grid-view.js.map +1 -0
- package/dist/hooks/use-drop-down-selection.d.ts +15 -0
- package/dist/hooks/use-drop-down-selection.d.ts.map +1 -0
- package/dist/hooks/use-drop-down-selection.js +35 -0
- package/dist/hooks/use-drop-down-selection.js.map +1 -0
- package/dist/hooks/use-header-menu.d.ts +4 -0
- package/dist/hooks/use-header-menu.d.ts.map +1 -0
- package/dist/hooks/use-header-menu.js +32 -0
- package/dist/hooks/use-header-menu.js.map +1 -0
- package/dist/hooks/use-list-model.d.ts +20 -0
- package/dist/hooks/use-list-model.d.ts.map +1 -0
- package/dist/hooks/use-list-model.js +70 -0
- package/dist/hooks/use-list-model.js.map +1 -0
- package/dist/hooks/use-model-installation.d.ts +9 -0
- package/dist/hooks/use-model-installation.d.ts.map +1 -0
- package/dist/hooks/use-model-installation.js +20 -0
- package/dist/hooks/use-model-installation.js.map +1 -0
- package/dist/hooks/use-realized-slots.d.ts +20 -0
- package/dist/hooks/use-realized-slots.d.ts.map +1 -0
- package/dist/hooks/use-realized-slots.js +66 -0
- package/dist/hooks/use-realized-slots.js.map +1 -0
- package/dist/hooks/use-selection-model.d.ts +12 -0
- package/dist/hooks/use-selection-model.d.ts.map +1 -0
- package/dist/hooks/use-selection-model.js +111 -0
- package/dist/hooks/use-selection-model.js.map +1 -0
- package/dist/hooks/use-sort-handler.d.ts +12 -0
- package/dist/hooks/use-sort-handler.d.ts.map +1 -0
- package/dist/hooks/use-sort-handler.js +87 -0
- package/dist/hooks/use-sort-handler.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/list-portal-host.d.ts +11 -0
- package/dist/list-portal-host.d.ts.map +1 -0
- package/dist/list-portal-host.js +8 -0
- package/dist/list-portal-host.js.map +1 -0
- package/dist/list-slot.d.ts +14 -0
- package/dist/list-slot.d.ts.map +1 -0
- package/dist/list-slot.js +29 -0
- package/dist/list-slot.js.map +1 -0
- package/dist/list-view.d.ts +16 -0
- package/dist/list-view.d.ts.map +1 -0
- package/dist/list-view.js +31 -0
- package/dist/list-view.js.map +1 -0
- package/dist/menu-items.d.ts +5 -0
- package/dist/menu-items.d.ts.map +1 -0
- package/dist/menu-items.js +48 -0
- package/dist/menu-items.js.map +1 -0
- package/dist/menu.d.ts +18 -0
- package/dist/menu.d.ts.map +1 -0
- package/dist/menu.js +26 -0
- package/dist/menu.js.map +1 -0
- package/dist/types.d.ts +198 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/item-resolver.d.ts +28 -0
- package/dist/utils/item-resolver.d.ts.map +1 -0
- package/dist/utils/item-resolver.js +111 -0
- package/dist/utils/item-resolver.js.map +1 -0
- package/dist/utils/list-item-flatten.d.ts +32 -0
- package/dist/utils/list-item-flatten.d.ts.map +1 -0
- package/dist/utils/list-item-flatten.js +88 -0
- package/dist/utils/list-item-flatten.js.map +1 -0
- package/dist/utils/position-only-model.d.ts +10 -0
- package/dist/utils/position-only-model.d.ts.map +1 -0
- package/dist/utils/position-only-model.js +83 -0
- package/dist/utils/position-only-model.js.map +1 -0
- package/dist/utils/realized-slot-store.d.ts +30 -0
- package/dist/utils/realized-slot-store.d.ts.map +1 -0
- package/dist/utils/realized-slot-store.js +103 -0
- package/dist/utils/realized-slot-store.js.map +1 -0
- package/package.json +55 -0
- package/src/collection-view.tsx +125 -0
- package/src/column-view-column.tsx +73 -0
- package/src/column-view.tsx +206 -0
- package/src/constraint-layout-apply.ts +121 -0
- package/src/constraint-layout.tsx +130 -0
- package/src/contexts/column-view-context.ts +28 -0
- package/src/drop-down.tsx +259 -0
- package/src/grid-view.tsx +73 -0
- package/src/hooks/use-drop-down-selection.ts +53 -0
- package/src/hooks/use-header-menu.ts +33 -0
- package/src/hooks/use-list-model.ts +145 -0
- package/src/hooks/use-model-installation.ts +25 -0
- package/src/hooks/use-realized-slots.ts +93 -0
- package/src/hooks/use-selection-model.ts +121 -0
- package/src/hooks/use-sort-handler.ts +102 -0
- package/src/index.ts +31 -0
- package/src/list-portal-host.tsx +27 -0
- package/src/list-slot.tsx +56 -0
- package/src/list-view.tsx +85 -0
- package/src/menu-items.ts +48 -0
- package/src/menu.tsx +34 -0
- package/src/types.ts +227 -0
- package/src/utils/item-resolver.ts +143 -0
- package/src/utils/list-item-flatten.ts +113 -0
- package/src/utils/position-only-model.ts +115 -0
- package/src/utils/realized-slot-store.ts +126 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as Gio from "@gtkx/gi/gio";
|
|
2
|
+
import * as GObject from "@gtkx/gi/gobject";
|
|
3
|
+
import type * as Gtk from "@gtkx/gi/gtk";
|
|
4
|
+
import { createPortal } from "@gtkx/react";
|
|
5
|
+
import { stateOf } from "@gtkx/react/internal";
|
|
6
|
+
import { type ReactNode, useLayoutEffect, useRef } from "react";
|
|
7
|
+
|
|
8
|
+
const findMenuModel = (container: GObject.Object): Gio.MenuModel | null => {
|
|
9
|
+
for (const child of stateOf(container).children) {
|
|
10
|
+
if (child instanceof Gio.MenuModel) return child;
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const useHeaderMenu = (column: Gtk.ColumnViewColumn | null, headerMenu: ReactNode): ReactNode => {
|
|
16
|
+
const containerRef = useRef<GObject.Object | null>(null);
|
|
17
|
+
if (containerRef.current === null) {
|
|
18
|
+
containerRef.current = new GObject.Object();
|
|
19
|
+
}
|
|
20
|
+
const container = containerRef.current;
|
|
21
|
+
|
|
22
|
+
useLayoutEffect(() => {
|
|
23
|
+
if (column === null) return;
|
|
24
|
+
if (headerMenu === undefined || headerMenu === null) {
|
|
25
|
+
column.setHeaderMenu(null);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
column.setHeaderMenu(findMenuModel(container));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (headerMenu === undefined || headerMenu === null) return null;
|
|
32
|
+
return createPortal(headerMenu, container, "header-menu");
|
|
33
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type * as Gio from "@gtkx/gi/gio";
|
|
2
|
+
import type * as GObject from "@gtkx/gi/gobject";
|
|
3
|
+
import type * as Gtk from "@gtkx/gi/gtk";
|
|
4
|
+
import { useLayoutEffect, useMemo, useRef } from "react";
|
|
5
|
+
import type { ListItem } from "../types.js";
|
|
6
|
+
import {
|
|
7
|
+
createControlledResolver,
|
|
8
|
+
createModelResolver,
|
|
9
|
+
createSectionHeaderResolver,
|
|
10
|
+
type ItemResolver,
|
|
11
|
+
type RowValue,
|
|
12
|
+
} from "../utils/item-resolver.js";
|
|
13
|
+
import { detectStructure, type ListStructure, structuralSignature } from "../utils/list-item-flatten.js";
|
|
14
|
+
import {
|
|
15
|
+
createFlatModel,
|
|
16
|
+
createSectionModel,
|
|
17
|
+
createTreeModel,
|
|
18
|
+
resizeFlatModel,
|
|
19
|
+
retagRows,
|
|
20
|
+
} from "../utils/position-only-model.js";
|
|
21
|
+
|
|
22
|
+
export interface ControlledListMode<T, S> {
|
|
23
|
+
items: ListItem<T, S>[] | undefined;
|
|
24
|
+
autoexpand?: boolean | undefined;
|
|
25
|
+
model?: never;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UncontrolledListMode {
|
|
29
|
+
model: Gio.ListModel;
|
|
30
|
+
items?: never;
|
|
31
|
+
autoexpand?: never;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ListModelResult<T, S> {
|
|
35
|
+
model: Gio.ListModel;
|
|
36
|
+
resolver: ItemResolver<T, S>;
|
|
37
|
+
headerResolver: ItemResolver<T, S>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ControlledState<T, S> {
|
|
41
|
+
model: Gio.ListModel;
|
|
42
|
+
flatModel: Gtk.StringList | undefined;
|
|
43
|
+
items: ListItem<T, S>[] | undefined;
|
|
44
|
+
structure: ListStructure;
|
|
45
|
+
autoexpand: boolean;
|
|
46
|
+
signature: string;
|
|
47
|
+
rowValues: WeakMap<GObject.Object, RowValue<T, S>>;
|
|
48
|
+
placeholdersById: Map<string, GObject.Object>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const controlledSignature = <T, S>(
|
|
52
|
+
structure: ListStructure,
|
|
53
|
+
autoexpand: boolean,
|
|
54
|
+
items: ListItem<T, S>[] | undefined,
|
|
55
|
+
): string => (structure === "flat" ? "" : `${structure}|${autoexpand ? 1 : 0}|${structuralSignature(items)}`);
|
|
56
|
+
|
|
57
|
+
const buildControlledState = <T, S>(
|
|
58
|
+
items: ListItem<T, S>[] | undefined,
|
|
59
|
+
autoexpand: boolean,
|
|
60
|
+
structure: ListStructure,
|
|
61
|
+
signature: string,
|
|
62
|
+
): ControlledState<T, S> => {
|
|
63
|
+
const rowValues = new WeakMap<GObject.Object, RowValue<T, S>>();
|
|
64
|
+
const placeholdersById = new Map<string, GObject.Object>();
|
|
65
|
+
let model: Gio.ListModel;
|
|
66
|
+
let flatModel: Gtk.StringList | undefined;
|
|
67
|
+
if (structure === "sections") {
|
|
68
|
+
model = createSectionModel(items ?? []);
|
|
69
|
+
} else if (structure === "tree") {
|
|
70
|
+
model = createTreeModel(items ?? [], autoexpand, rowValues, placeholdersById);
|
|
71
|
+
} else {
|
|
72
|
+
flatModel = createFlatModel(items?.length ?? 0);
|
|
73
|
+
model = flatModel;
|
|
74
|
+
}
|
|
75
|
+
return { model, flatModel, items, structure, autoexpand, signature, rowValues, placeholdersById };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const resolveControlledState = <T, S>(
|
|
79
|
+
prev: ControlledState<T, S> | null,
|
|
80
|
+
items: ListItem<T, S>[] | undefined,
|
|
81
|
+
autoexpand: boolean,
|
|
82
|
+
): ControlledState<T, S> => {
|
|
83
|
+
if (prev !== null && prev.items === items && prev.autoexpand === autoexpand) return prev;
|
|
84
|
+
const structure = detectStructure(items);
|
|
85
|
+
const signature = controlledSignature(structure, autoexpand, items);
|
|
86
|
+
if (prev === null || prev.structure !== structure) {
|
|
87
|
+
return buildControlledState(items, autoexpand, structure, signature);
|
|
88
|
+
}
|
|
89
|
+
if (structure !== "flat" && prev.signature !== signature) {
|
|
90
|
+
return buildControlledState(items, autoexpand, structure, signature);
|
|
91
|
+
}
|
|
92
|
+
if (structure !== "flat") {
|
|
93
|
+
retagRows(items ?? [], prev.rowValues, prev.placeholdersById);
|
|
94
|
+
}
|
|
95
|
+
prev.items = items;
|
|
96
|
+
prev.autoexpand = autoexpand;
|
|
97
|
+
prev.signature = signature;
|
|
98
|
+
return prev;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const useControlledModel = <T, S>(mode: ControlledListMode<T, S>): ListModelResult<T, S> => {
|
|
102
|
+
const { items } = mode;
|
|
103
|
+
const autoexpand = mode.autoexpand ?? false;
|
|
104
|
+
const stateRef = useRef<ControlledState<T, S> | null>(null);
|
|
105
|
+
const state = resolveControlledState(stateRef.current, items, autoexpand);
|
|
106
|
+
stateRef.current = state;
|
|
107
|
+
const { structure, flatModel } = state;
|
|
108
|
+
|
|
109
|
+
useLayoutEffect(() => {
|
|
110
|
+
if (flatModel !== undefined) {
|
|
111
|
+
resizeFlatModel(flatModel, items?.length ?? 0);
|
|
112
|
+
}
|
|
113
|
+
}, [flatModel, items]);
|
|
114
|
+
|
|
115
|
+
const resolver = useMemo(
|
|
116
|
+
() => createControlledResolver(items, structure !== "flat" && autoexpand, state.rowValues),
|
|
117
|
+
[items, structure, autoexpand, state.rowValues],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const headerResolver = useMemo(
|
|
121
|
+
() => createSectionHeaderResolver(structure === "sections" ? items : undefined),
|
|
122
|
+
[items, structure],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return { model: state.model, resolver, headerResolver };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const useUncontrolledModel = <T, S>(mode: UncontrolledListMode): ListModelResult<T, S> => {
|
|
129
|
+
const { model } = mode;
|
|
130
|
+
const resolver = useMemo<ItemResolver<T, S>>(() => createModelResolver(model), [model]);
|
|
131
|
+
const headerResolver = useMemo<ItemResolver<T, S>>(() => createSectionHeaderResolver<T, S>(undefined), []);
|
|
132
|
+
return { model, resolver, headerResolver };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const useListModel = <T, S>(mode: ControlledListMode<T, S> | UncontrolledListMode): ListModelResult<T, S> => {
|
|
136
|
+
const controlled = useControlledModel<T, S>(
|
|
137
|
+
mode.model === undefined ? { items: mode.items, autoexpand: mode.autoexpand } : { items: [] },
|
|
138
|
+
);
|
|
139
|
+
const uncontrolled = useUncontrolledModel<T, S>(
|
|
140
|
+
mode.model === undefined ? { model: EMPTY_MODEL } : { model: mode.model },
|
|
141
|
+
);
|
|
142
|
+
return mode.model === undefined ? controlled : uncontrolled;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const EMPTY_MODEL: Gio.ListModel = createFlatModel(0);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type * as Gtk from "@gtkx/gi/gtk";
|
|
2
|
+
import { useTargetRegistration } from "@gtkx/react/internal";
|
|
3
|
+
import { type RefObject, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Installs `model` onto the realized GTK widget referenced by `target` through
|
|
7
|
+
* the `install` callback, re-running whenever the widget or model identity
|
|
8
|
+
* changes and tolerating the widget being realized after mount.
|
|
9
|
+
*/
|
|
10
|
+
export const useModelInstallation = <W extends Gtk.Widget, M>(
|
|
11
|
+
target: RefObject<W | null>,
|
|
12
|
+
model: M,
|
|
13
|
+
install: (widget: W, model: M) => void,
|
|
14
|
+
): void => {
|
|
15
|
+
const installRef = useRef(install);
|
|
16
|
+
installRef.current = install;
|
|
17
|
+
useTargetRegistration<W, { widget: W; model: M }>(target, {
|
|
18
|
+
attach: (widget) => {
|
|
19
|
+
installRef.current(widget, model);
|
|
20
|
+
return { widget, model };
|
|
21
|
+
},
|
|
22
|
+
detach: () => {},
|
|
23
|
+
isSame: (registration, widget) => registration.widget === widget && registration.model === model,
|
|
24
|
+
});
|
|
25
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type * as GObject from "@gtkx/gi/gobject";
|
|
2
|
+
import * as Gtk from "@gtkx/gi/gtk";
|
|
3
|
+
import { type GObjectTarget, useTargetRegistration } from "@gtkx/react/internal";
|
|
4
|
+
import { useRef } from "react";
|
|
5
|
+
import { RealizedSlotStore } from "../utils/realized-slot-store.js";
|
|
6
|
+
|
|
7
|
+
interface ChildContainer {
|
|
8
|
+
getChild(): Gtk.Widget | null;
|
|
9
|
+
setChild(child: Gtk.Widget | null): void;
|
|
10
|
+
getItem(): GObject.Object | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const isChildContainer = (container: GObject.Object): container is GObject.Object & ChildContainer =>
|
|
14
|
+
container instanceof Gtk.ListItem || container instanceof Gtk.ListHeader;
|
|
15
|
+
|
|
16
|
+
const positionOf = (container: GObject.Object & ChildContainer): number => {
|
|
17
|
+
if (container instanceof Gtk.ListItem) return container.getPosition();
|
|
18
|
+
if (container instanceof Gtk.ListHeader) return container.getStart();
|
|
19
|
+
return -1;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface FactoryBinding<W extends GObject.Object> {
|
|
23
|
+
install(widget: W, factory: Gtk.SignalListItemFactory): void;
|
|
24
|
+
uninstall(widget: W, factory: Gtk.SignalListItemFactory): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RealizedSlotsOptions<W extends GObject.Object> {
|
|
28
|
+
target: GObjectTarget<W>;
|
|
29
|
+
binding: FactoryBinding<W>;
|
|
30
|
+
estimatedHeight?: number | undefined;
|
|
31
|
+
estimatedWidth?: number | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RealizedSlots {
|
|
35
|
+
factory: Gtk.SignalListItemFactory;
|
|
36
|
+
store: RealizedSlotStore;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const applyEstimatedSize = (child: Gtk.Widget, height: number | undefined, width: number | undefined): void => {
|
|
40
|
+
if (height === undefined && width === undefined) return;
|
|
41
|
+
child.setSizeRequest(width ?? -1, height ?? -1);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const useRealizedSlots = <W extends GObject.Object>(options: RealizedSlotsOptions<W>): RealizedSlots => {
|
|
45
|
+
const { target, binding, estimatedHeight, estimatedWidth } = options;
|
|
46
|
+
const storeRef = useRef<RealizedSlotStore | null>(null);
|
|
47
|
+
if (storeRef.current === null) storeRef.current = new RealizedSlotStore();
|
|
48
|
+
const store = storeRef.current;
|
|
49
|
+
|
|
50
|
+
const bindingRef = useRef(binding);
|
|
51
|
+
bindingRef.current = binding;
|
|
52
|
+
|
|
53
|
+
const factoryRef = useRef<Gtk.SignalListItemFactory | null>(null);
|
|
54
|
+
if (factoryRef.current === null) {
|
|
55
|
+
const factory = Gtk.SignalListItemFactory.new();
|
|
56
|
+
factory.on("setup", (container) => {
|
|
57
|
+
if (isChildContainer(container) && container.getChild() === null) {
|
|
58
|
+
const placeholder = new Gtk.Box();
|
|
59
|
+
applyEstimatedSize(placeholder, estimatedHeight, estimatedWidth);
|
|
60
|
+
container.setChild(placeholder);
|
|
61
|
+
}
|
|
62
|
+
store.addContainer(container);
|
|
63
|
+
});
|
|
64
|
+
factory.on("bind", (container) => {
|
|
65
|
+
if (isChildContainer(container)) {
|
|
66
|
+
const item = container.getItem();
|
|
67
|
+
const treeRow = item instanceof Gtk.TreeListRow ? item : null;
|
|
68
|
+
store.bind(container, positionOf(container), treeRow, item);
|
|
69
|
+
} else {
|
|
70
|
+
store.bind(container, -1, null, null);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
factory.on("unbind", (container) => {
|
|
74
|
+
store.unbind(container);
|
|
75
|
+
});
|
|
76
|
+
factory.on("teardown", (container) => {
|
|
77
|
+
store.removeContainer(container);
|
|
78
|
+
});
|
|
79
|
+
factoryRef.current = factory;
|
|
80
|
+
}
|
|
81
|
+
const factory = factoryRef.current;
|
|
82
|
+
|
|
83
|
+
useTargetRegistration<W, { widget: W }>(target, {
|
|
84
|
+
attach: (widget) => {
|
|
85
|
+
bindingRef.current.install(widget, factory);
|
|
86
|
+
return { widget };
|
|
87
|
+
},
|
|
88
|
+
detach: (registration) => bindingRef.current.uninstall(registration.widget, factory),
|
|
89
|
+
isSame: (registration, widget) => registration.widget === widget,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return { factory, store };
|
|
93
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type * as Gio from "@gtkx/gi/gio";
|
|
2
|
+
import * as Gtk from "@gtkx/gi/gtk";
|
|
3
|
+
import { useSignal } from "@gtkx/react";
|
|
4
|
+
import { useLayoutEffect, useRef } from "react";
|
|
5
|
+
import type { ItemResolver } from "../utils/item-resolver.js";
|
|
6
|
+
|
|
7
|
+
export interface SelectionModelOptions<T, S> {
|
|
8
|
+
base: Gio.ListModel;
|
|
9
|
+
selectionMode: Gtk.SelectionMode | null | undefined;
|
|
10
|
+
selected: string[] | null | undefined;
|
|
11
|
+
onSelectionChanged: ((ids: string[]) => void) | null | undefined;
|
|
12
|
+
resolver: ItemResolver<T, S>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type WrappingSelectionModel = (Gtk.SingleSelection | Gtk.MultiSelection | Gtk.NoSelection) & Gtk.SelectionModel;
|
|
16
|
+
|
|
17
|
+
const createSelectionModel = (mode: Gtk.SelectionMode, base: Gio.ListModel): WrappingSelectionModel => {
|
|
18
|
+
if (mode === Gtk.SelectionMode.MULTIPLE) return Gtk.MultiSelection.new(base);
|
|
19
|
+
if (mode === Gtk.SelectionMode.NONE) return Gtk.NoSelection.new(base);
|
|
20
|
+
return new Gtk.SingleSelection({ model: base, autoselect: false, canUnselect: true });
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const bitsetOf = (positions: number[]): Gtk.Bitset => {
|
|
24
|
+
const bitset = Gtk.Bitset.newEmpty();
|
|
25
|
+
for (const position of positions) {
|
|
26
|
+
if (position >= 0) bitset.add(position);
|
|
27
|
+
}
|
|
28
|
+
return bitset;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const applySelectedPositions = (model: Gtk.SelectionModel, positions: number[]): void => {
|
|
32
|
+
if (model instanceof Gtk.MultiSelection) {
|
|
33
|
+
if (positions.length === 0) {
|
|
34
|
+
const current = model.getSelection();
|
|
35
|
+
if (!current.isEmpty()) model.unselectAll();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const requested = bitsetOf(positions);
|
|
39
|
+
const changed = model.getSelection();
|
|
40
|
+
changed.difference(requested);
|
|
41
|
+
if (changed.isEmpty()) return;
|
|
42
|
+
model.setSelection(requested, changed);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (positions.length === 0) {
|
|
46
|
+
model.unselectAll();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const first = positions[0];
|
|
50
|
+
if (first !== undefined && first >= 0) model.selectItem(first, true);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const readSelectedIds = <T, S>(model: Gtk.SelectionModel, resolver: ItemResolver<T, S>): string[] => {
|
|
54
|
+
const selection = model.getSelection();
|
|
55
|
+
const ids: string[] = [];
|
|
56
|
+
const count = Number(selection.getSize());
|
|
57
|
+
for (let index = 0; index < count; index++) {
|
|
58
|
+
const position = selection.getNth(index);
|
|
59
|
+
const id = resolver.idOf(position);
|
|
60
|
+
if (id !== undefined) ids.push(id);
|
|
61
|
+
}
|
|
62
|
+
return ids;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const idsToPositions = <T, S>(ids: string[] | null | undefined, resolver: ItemResolver<T, S>): number[] => {
|
|
66
|
+
if (ids === undefined || ids === null) return [];
|
|
67
|
+
const positions: number[] = [];
|
|
68
|
+
for (const id of ids) {
|
|
69
|
+
const position = resolver.positionOf(id);
|
|
70
|
+
if (position >= 0) positions.push(position);
|
|
71
|
+
}
|
|
72
|
+
return positions;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const sameIds = (a: string[], b: string[]): boolean => {
|
|
76
|
+
if (a.length !== b.length) return false;
|
|
77
|
+
for (let index = 0; index < a.length; index++) {
|
|
78
|
+
if (a[index] !== b[index]) return false;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const useSelectionModel = <T, S>(options: SelectionModelOptions<T, S>): Gtk.SelectionModel => {
|
|
84
|
+
const { base, selectionMode, selected, onSelectionChanged, resolver } = options;
|
|
85
|
+
const mode = selectionMode ?? Gtk.SelectionMode.SINGLE;
|
|
86
|
+
|
|
87
|
+
const modelRef = useRef<WrappingSelectionModel | null>(null);
|
|
88
|
+
const modeRef = useRef<Gtk.SelectionMode | null>(null);
|
|
89
|
+
if (modelRef.current === null || modeRef.current !== mode) {
|
|
90
|
+
modelRef.current = createSelectionModel(mode, base);
|
|
91
|
+
modeRef.current = mode;
|
|
92
|
+
}
|
|
93
|
+
const model = modelRef.current;
|
|
94
|
+
|
|
95
|
+
const onChangedRef = useRef(onSelectionChanged);
|
|
96
|
+
onChangedRef.current = onSelectionChanged;
|
|
97
|
+
const resolverRef = useRef(resolver);
|
|
98
|
+
resolverRef.current = resolver;
|
|
99
|
+
const lastReportedRef = useRef<string[] | null>(null);
|
|
100
|
+
|
|
101
|
+
const report = (): void => {
|
|
102
|
+
const callback = onChangedRef.current;
|
|
103
|
+
if (!callback) return;
|
|
104
|
+
const ids = readSelectedIds(model, resolverRef.current);
|
|
105
|
+
if (lastReportedRef.current !== null && sameIds(lastReportedRef.current, ids)) return;
|
|
106
|
+
lastReportedRef.current = ids;
|
|
107
|
+
callback(ids);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
useSignal(model, "selection-changed", report);
|
|
111
|
+
|
|
112
|
+
useLayoutEffect(() => {
|
|
113
|
+
if (model.getModel() !== base) model.setModel(base);
|
|
114
|
+
if (selected !== undefined && selected !== null) {
|
|
115
|
+
applySelectedPositions(model, idsToPositions(selected, resolver));
|
|
116
|
+
}
|
|
117
|
+
report();
|
|
118
|
+
}, [model, base, selected, resolver]);
|
|
119
|
+
|
|
120
|
+
return model;
|
|
121
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as Gtk from "@gtkx/gi/gtk";
|
|
2
|
+
import { type GObjectTarget, resolveGobjectTarget } from "@gtkx/react/internal";
|
|
3
|
+
import { useLayoutEffect, useRef } from "react";
|
|
4
|
+
import type { ColumnRegistration } from "../contexts/column-view-context.js";
|
|
5
|
+
|
|
6
|
+
export interface SortHandlerOptions {
|
|
7
|
+
columnView: GObjectTarget<Gtk.ColumnView>;
|
|
8
|
+
sortColumn: string | null | undefined;
|
|
9
|
+
sortOrder: Gtk.SortType | null | undefined;
|
|
10
|
+
onSortChanged: ((column: string | null, order: Gtk.SortType) => void) | null | undefined;
|
|
11
|
+
columns: ColumnRegistration[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const neutralSorter = (): Gtk.CustomSorter => Gtk.CustomSorter.new(() => 0);
|
|
15
|
+
|
|
16
|
+
const ensureColumnSorters = (columns: ColumnRegistration[], activeId: string | null): void => {
|
|
17
|
+
for (const registration of columns) {
|
|
18
|
+
const wantsSorter = registration.sortable || registration.id === activeId;
|
|
19
|
+
const hasSorter = registration.column.getSorter() !== null;
|
|
20
|
+
if (wantsSorter && !hasSorter) {
|
|
21
|
+
registration.column.setSorter(neutralSorter());
|
|
22
|
+
} else if (!wantsSorter && hasSorter) {
|
|
23
|
+
registration.column.setSorter(null);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const columnForId = (columns: ColumnRegistration[], id: string | null): Gtk.ColumnViewColumn | null => {
|
|
29
|
+
if (id === null) return null;
|
|
30
|
+
for (const registration of columns) {
|
|
31
|
+
if (registration.id === id) return registration.column;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const idForColumn = (columns: ColumnRegistration[], column: Gtk.ColumnViewColumn | null): string | null => {
|
|
37
|
+
if (column === null) return null;
|
|
38
|
+
for (const registration of columns) {
|
|
39
|
+
if (registration.column === column) return registration.id;
|
|
40
|
+
}
|
|
41
|
+
return column.getId();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const primarySort = (sorter: Gtk.Sorter): { column: Gtk.ColumnViewColumn | null; order: Gtk.SortType } =>
|
|
45
|
+
sorter instanceof Gtk.ColumnViewSorter
|
|
46
|
+
? { column: sorter.getPrimarySortColumn(), order: sorter.getPrimarySortOrder() }
|
|
47
|
+
: { column: null, order: Gtk.SortType.ASCENDING };
|
|
48
|
+
|
|
49
|
+
interface SorterSubscription {
|
|
50
|
+
sorter: Gtk.Sorter | null;
|
|
51
|
+
handler: ((change: Gtk.SorterChange) => void) | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const disconnectSorter = (subscription: SorterSubscription): void => {
|
|
55
|
+
if (subscription.sorter !== null && subscription.handler !== null) {
|
|
56
|
+
subscription.sorter.off("changed", subscription.handler);
|
|
57
|
+
}
|
|
58
|
+
subscription.sorter = null;
|
|
59
|
+
subscription.handler = null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const useSortHandler = (options: SortHandlerOptions): void => {
|
|
63
|
+
const { columnView, sortColumn, sortOrder, onSortChanged, columns } = options;
|
|
64
|
+
const activeId = sortColumn ?? null;
|
|
65
|
+
const order = sortOrder ?? Gtk.SortType.ASCENDING;
|
|
66
|
+
|
|
67
|
+
const onChangedRef = useRef(onSortChanged);
|
|
68
|
+
onChangedRef.current = onSortChanged;
|
|
69
|
+
const columnsRef = useRef(columns);
|
|
70
|
+
columnsRef.current = columns;
|
|
71
|
+
const suppressRef = useRef(false);
|
|
72
|
+
const subscriptionRef = useRef<SorterSubscription>({ sorter: null, handler: null });
|
|
73
|
+
|
|
74
|
+
const resubscribe = (sorter: Gtk.Sorter | null): void => {
|
|
75
|
+
const subscription = subscriptionRef.current;
|
|
76
|
+
if (sorter === subscription.sorter) return;
|
|
77
|
+
disconnectSorter(subscription);
|
|
78
|
+
if (sorter === null) return;
|
|
79
|
+
const handler = (): void => {
|
|
80
|
+
if (suppressRef.current) return;
|
|
81
|
+
const callback = onChangedRef.current;
|
|
82
|
+
if (!callback) return;
|
|
83
|
+
const { column, order: primaryOrder } = primarySort(sorter);
|
|
84
|
+
callback(idForColumn(columnsRef.current, column), primaryOrder);
|
|
85
|
+
};
|
|
86
|
+
subscription.sorter = sorter;
|
|
87
|
+
subscription.handler = handler;
|
|
88
|
+
sorter.on("changed", handler);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
useLayoutEffect(() => {
|
|
92
|
+
const view = resolveGobjectTarget(columnView);
|
|
93
|
+
if (view === null) return;
|
|
94
|
+
ensureColumnSorters(columns, activeId);
|
|
95
|
+
suppressRef.current = true;
|
|
96
|
+
view.sortByColumn(columnForId(columns, activeId), order);
|
|
97
|
+
suppressRef.current = false;
|
|
98
|
+
resubscribe(view.getSorter());
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
useLayoutEffect(() => () => disconnectSorter(subscriptionRef.current), []);
|
|
102
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Higher-level GTKX components: declarative, opinionated abstractions layered on
|
|
3
|
+
* top of the generated `@gtkx/jsx` element bindings and the `@gtkx/react`
|
|
4
|
+
* runtime. Each component pairs a GObject widget with a gtkx-authored prop
|
|
5
|
+
* surface (item models, render callbacks, selection, sorting) and is exported
|
|
6
|
+
* under an unprefixed name to distinguish it from the raw `Gtk*`/`Adw*`/`G*`
|
|
7
|
+
* intrinsic element it builds upon.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { ColumnView, type ColumnViewComponentProps } from "./column-view.js";
|
|
13
|
+
export { ColumnViewColumn, type ColumnViewColumnComponentProps } from "./column-view-column.js";
|
|
14
|
+
export { ConstraintLayout, type ConstraintLayoutProps } from "./constraint-layout.js";
|
|
15
|
+
export { ComboRow, type ComboRowComponentProps, DropDown, type DropDownComponentProps } from "./drop-down.js";
|
|
16
|
+
export { GridView, type GridViewComponentProps } from "./grid-view.js";
|
|
17
|
+
export { ListView, type ListViewComponentProps } from "./list-view.js";
|
|
18
|
+
export { Menu, type MenuProps } from "./menu.js";
|
|
19
|
+
export type {
|
|
20
|
+
ColumnViewColumnProps,
|
|
21
|
+
ColumnViewProps,
|
|
22
|
+
ConstraintGuideProps,
|
|
23
|
+
ConstraintProps,
|
|
24
|
+
ConstraintVflProps,
|
|
25
|
+
DropDownProps,
|
|
26
|
+
GridViewProps,
|
|
27
|
+
ListItem,
|
|
28
|
+
ListViewProps,
|
|
29
|
+
MenuEntry,
|
|
30
|
+
MenuItemsProps,
|
|
31
|
+
} from "./types.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Fragment, type ReactNode, useSyncExternalStore } from "react";
|
|
2
|
+
import { ListSlot, type SlotRenderer } from "./list-slot.js";
|
|
3
|
+
import type { ItemResolver } from "./utils/item-resolver.js";
|
|
4
|
+
import type { RealizedSlotStore } from "./utils/realized-slot-store.js";
|
|
5
|
+
|
|
6
|
+
export interface ListPortalHostProps<T, S> {
|
|
7
|
+
store: RealizedSlotStore;
|
|
8
|
+
resolver: ItemResolver<T, S>;
|
|
9
|
+
render: SlotRenderer<T, S>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const ListPortalHost = <T, S>({ store, resolver, render }: ListPortalHostProps<T, S>): ReactNode => {
|
|
13
|
+
const containers = useSyncExternalStore(store.subscribeSet, store.getSlotsSnapshot, store.getSlotsSnapshot);
|
|
14
|
+
return (
|
|
15
|
+
<Fragment>
|
|
16
|
+
{containers.map((container) => (
|
|
17
|
+
<ListSlot
|
|
18
|
+
key={store.keyFor(container)}
|
|
19
|
+
container={container}
|
|
20
|
+
store={store}
|
|
21
|
+
resolver={resolver}
|
|
22
|
+
render={render}
|
|
23
|
+
/>
|
|
24
|
+
))}
|
|
25
|
+
</Fragment>
|
|
26
|
+
);
|
|
27
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type * as GObject from "@gtkx/gi/gobject";
|
|
2
|
+
import type * as Gtk from "@gtkx/gi/gtk";
|
|
3
|
+
import { GtkTreeExpander } from "@gtkx/jsx/gtk";
|
|
4
|
+
import { createPortal } from "@gtkx/react";
|
|
5
|
+
import { createElement, memo, type ReactNode, useCallback, useSyncExternalStore } from "react";
|
|
6
|
+
import type { ItemResolver } from "./utils/item-resolver.js";
|
|
7
|
+
import type { TreeItemMetadata } from "./utils/list-item-flatten.js";
|
|
8
|
+
import type { RealizedSlotStore, SlotEntry } from "./utils/realized-slot-store.js";
|
|
9
|
+
|
|
10
|
+
export type SlotRenderer<T, S> = (
|
|
11
|
+
value: T | S | undefined,
|
|
12
|
+
treeRow: Gtk.TreeListRow | null,
|
|
13
|
+
isHeader: boolean,
|
|
14
|
+
) => ReactNode;
|
|
15
|
+
|
|
16
|
+
export interface ListSlotProps<T, S> {
|
|
17
|
+
container: GObject.Object;
|
|
18
|
+
store: RealizedSlotStore;
|
|
19
|
+
resolver: ItemResolver<T, S>;
|
|
20
|
+
render: SlotRenderer<T, S>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const wrapInTreeExpander = (content: ReactNode, treeRow: Gtk.TreeListRow, metadata: TreeItemMetadata): ReactNode =>
|
|
24
|
+
createElement(
|
|
25
|
+
GtkTreeExpander,
|
|
26
|
+
{
|
|
27
|
+
ref: (expander: Gtk.TreeExpander | null) => {
|
|
28
|
+
if (expander !== null) expander.setListRow(treeRow);
|
|
29
|
+
},
|
|
30
|
+
hideExpander: metadata.hideExpander,
|
|
31
|
+
indentForDepth: metadata.indentForDepth,
|
|
32
|
+
indentForIcon: metadata.indentForIcon,
|
|
33
|
+
},
|
|
34
|
+
content,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const SlotImpl = <T, S>({ container, store, resolver, render }: ListSlotProps<T, S>): ReactNode => {
|
|
38
|
+
const subscribe = useCallback(
|
|
39
|
+
(onChange: () => void) => store.subscribePosition(container, onChange),
|
|
40
|
+
[store, container],
|
|
41
|
+
);
|
|
42
|
+
const getSnapshot = useCallback((): SlotEntry => store.getPosition(container), [store, container]);
|
|
43
|
+
const entry = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
44
|
+
|
|
45
|
+
if (entry.position < 0) return null;
|
|
46
|
+
const resolved = resolver.resolve(entry.position, entry.treeRow, entry.item);
|
|
47
|
+
if (!resolved.present) return null;
|
|
48
|
+
const content = render(resolved.value, resolved.treeRow, resolved.isHeader);
|
|
49
|
+
const portalled =
|
|
50
|
+
resolved.treeRow !== null && !resolved.isHeader
|
|
51
|
+
? wrapInTreeExpander(content, resolved.treeRow, resolved.metadata)
|
|
52
|
+
: content;
|
|
53
|
+
return createPortal(portalled, container, store.keyFor(container));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const ListSlot = memo(SlotImpl) as <T, S>(props: ListSlotProps<T, S>) => ReactNode;
|