@gtkx/react 0.1.47 → 0.1.48

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 CHANGED
@@ -186,6 +186,14 @@ A comprehensive showcase of GTK4 widgets and features:
186
186
  turbo start --filter=gtk4-demo
187
187
  ```
188
188
 
189
+ ### List Example
190
+
191
+ Comprehensive showcase of ListView, GridView, and ColumnView with sorting:
192
+
193
+ ```bash
194
+ turbo start --filter=list-example
195
+ ```
196
+
189
197
  ## Packages
190
198
 
191
199
  | Package | Description |
@@ -136,14 +136,14 @@ export class JsxGenerator {
136
136
  `import type { ReactNode, Ref } from "react";`,
137
137
  ...externalImports,
138
138
  `import type * as Gtk from "@gtkx/ffi/gtk";`,
139
- `import type { ColumnViewColumnProps, GridChildProps, ListItemProps, ListViewRenderProps, NotebookPageProps, SlotProps } from "../types.js";`,
139
+ `import type { ColumnViewColumnProps, ColumnViewRootProps, GridChildProps, ListItemProps, ListViewRenderProps, NotebookPageProps, SlotProps } from "../types.js";`,
140
140
  "",
141
141
  ].join("\n");
142
142
  }
143
143
  generateCommonTypes(widgetClass) {
144
144
  const widgetPropsContent = this.generateWidgetPropsContent(widgetClass);
145
145
  return `
146
- export { ColumnViewColumnProps, GridChildProps, ListItemProps, ListViewRenderProps, NotebookPageProps, SlotProps };
146
+ export { ColumnViewColumnProps, ColumnViewRootProps, GridChildProps, ListItemProps, ListViewRenderProps, NotebookPageProps, SlotProps };
147
147
 
148
148
  ${widgetPropsContent}
149
149
  `;
@@ -664,8 +664,15 @@ ${widgetPropsContent}
664
664
  lines.push(`}`);
665
665
  }
666
666
  else if (isColumnViewWidget(widgetName)) {
667
- // Root wrapper (non-generic)
668
- lines.push(`function ${name}Root(props: ${name}Props): import("react").ReactElement {`);
667
+ // Root props type - extends with ColumnViewRootProps for sorting support
668
+ // Uses generics: T for item type, C for column ID union type
669
+ lines.push(`interface ${name}RootPropsExtended<T = unknown, C extends string = string> extends ${name}Props, ColumnViewRootProps<C> {`);
670
+ lines.push(`\t/** Comparison function for sorting items by column. Takes item a, item b, and column id. */`);
671
+ lines.push(`\tsortFn?: (a: T, b: T, columnId: C) => number;`);
672
+ lines.push(`}`);
673
+ lines.push(``);
674
+ // Root wrapper (generic)
675
+ lines.push(`function ${name}Root<T = unknown, C extends string = string>(props: ${name}RootPropsExtended<T, C>): import("react").ReactElement {`);
669
676
  lines.push(`\treturn createElement("${name}.Root", props);`);
670
677
  lines.push(`}`);
671
678
  lines.push(``);
@@ -3,8 +3,8 @@ import type { ReactNode, Ref } from "react";
3
3
  import type * as Gdk from "@gtkx/ffi/gdk";
4
4
  import type * as Gio from "@gtkx/ffi/gio";
5
5
  import type * as Gtk from "@gtkx/ffi/gtk";
6
- import type { ColumnViewColumnProps, GridChildProps, ListItemProps, ListViewRenderProps, NotebookPageProps, SlotProps } from "../types.js";
7
- export { ColumnViewColumnProps, GridChildProps, ListItemProps, ListViewRenderProps, NotebookPageProps, SlotProps, };
6
+ import type { ColumnViewColumnProps, ColumnViewRootProps, GridChildProps, ListItemProps, ListViewRenderProps, NotebookPageProps, SlotProps } from "../types.js";
7
+ export { ColumnViewColumnProps, ColumnViewRootProps, GridChildProps, ListItemProps, ListViewRenderProps, NotebookPageProps, SlotProps, };
8
8
  /**
9
9
  * The base class for all widgets.
10
10
  *
@@ -7075,7 +7075,11 @@ export declare const ColorChooserWidget: "ColorChooserWidget";
7075
7075
  * it gets the .color style class.
7076
7076
  */
7077
7077
  export declare const ColorDialogButton: "ColorDialogButton";
7078
- declare function ColumnViewRoot(props: ColumnViewProps): import("react").ReactElement;
7078
+ interface ColumnViewRootPropsExtended<T = unknown, C extends string = string> extends ColumnViewProps, ColumnViewRootProps<C> {
7079
+ /** Comparison function for sorting items by column. Takes item a, item b, and column id. */
7080
+ sortFn?: (a: T, b: T, columnId: C) => number;
7081
+ }
7082
+ declare function ColumnViewRoot<T = unknown, C extends string = string>(props: ColumnViewRootPropsExtended<T, C>): import("react").ReactElement;
7079
7083
  interface ColumnViewGenericColumnProps<T> extends Omit<ColumnViewColumnProps, "renderCell"> {
7080
7084
  /** Render function for column cells. Called with null during setup. */
7081
7085
  renderCell: (item: T | null) => import("react").ReactElement;
@@ -1,34 +1,58 @@
1
1
  import * as Gtk from "@gtkx/ffi/gtk";
2
2
  import type { Props } from "../factory.js";
3
3
  import { Node } from "../node.js";
4
+ import type { ColumnSortFn } from "../types.js";
4
5
  export declare class ColumnViewNode extends Node<Gtk.ColumnView> {
5
6
  static matches(type: string): boolean;
6
7
  private stringList;
7
8
  private selectionModel;
9
+ private sortListModel;
8
10
  private items;
9
11
  private columns;
10
12
  private committedLength;
13
+ private sortColumn;
14
+ private sortOrder;
15
+ private sortFn;
16
+ private isSorting;
17
+ private onSortChange;
18
+ private sorterChangedHandlerId;
19
+ private lastNotifiedColumn;
20
+ private lastNotifiedOrder;
11
21
  constructor(type: string, props: Props, app: Gtk.Application);
22
+ private connectSorterChangedSignal;
23
+ private waitForSortComplete;
24
+ private disconnectSorterChangedSignal;
25
+ private notifySortChange;
12
26
  getItems(): unknown[];
13
- addColumn(column: ColumnViewColumnNode): void;
27
+ getSortFn(): ColumnSortFn<unknown, string> | null;
28
+ compareItems(a: unknown, b: unknown, columnId: string): number;
29
+ addColumn(columnNode: ColumnViewColumnNode): void;
30
+ private applySortByColumn;
31
+ findColumnById(id: string): ColumnViewColumnNode | undefined;
14
32
  removeColumn(column: ColumnViewColumnNode): void;
15
33
  insertColumnBefore(column: ColumnViewColumnNode, before: ColumnViewColumnNode): void;
16
34
  private syncStringList;
17
35
  addItem(item: unknown): void;
18
36
  insertItemBefore(item: unknown, beforeItem: unknown): void;
19
37
  removeItem(item: unknown): void;
38
+ protected consumedProps(): Set<string>;
39
+ updateProps(oldProps: Props, newProps: Props): void;
20
40
  }
21
41
  export declare class ColumnViewColumnNode extends Node {
22
42
  static matches(type: string): boolean;
23
43
  protected isVirtual(): boolean;
24
- private gtkColumn;
44
+ private column;
25
45
  private factory;
26
46
  private renderCell;
27
47
  private columnView;
28
48
  private listItemCache;
49
+ private columnId;
50
+ private sorter;
29
51
  constructor(type: string, props: Props, app: Gtk.Application);
30
- getGtkColumn(): Gtk.ColumnViewColumn;
52
+ getColumn(): Gtk.ColumnViewColumn;
53
+ getId(): string | null;
31
54
  setColumnView(columnView: ColumnViewNode | null): void;
55
+ updateSorterFromRoot(): void;
32
56
  attachToParent(parent: Node): void;
33
57
  attachToParentBefore(parent: Node, before: Node): void;
34
58
  detachFromParent(parent: Node): void;
@@ -1,4 +1,5 @@
1
1
  import { getObject, getObjectId } from "@gtkx/ffi";
2
+ import * as GObject from "@gtkx/ffi/gobject";
2
3
  import * as Gtk from "@gtkx/ffi/gtk";
3
4
  import { scheduleFlush } from "../batch.js";
4
5
  import { createFiberRoot } from "../fiber-root.js";
@@ -10,29 +11,123 @@ export class ColumnViewNode extends Node {
10
11
  }
11
12
  stringList;
12
13
  selectionModel;
14
+ sortListModel;
13
15
  items = [];
14
16
  columns = [];
15
17
  committedLength = 0;
18
+ sortColumn = null;
19
+ sortOrder = Gtk.SortType.ASCENDING;
20
+ sortFn = null;
21
+ isSorting = false;
22
+ onSortChange = null;
23
+ sorterChangedHandlerId = null;
24
+ lastNotifiedColumn = null;
25
+ lastNotifiedOrder = Gtk.SortType.ASCENDING;
16
26
  constructor(type, props, app) {
17
27
  super(type, props, app);
18
28
  this.stringList = new Gtk.StringList([]);
19
- this.selectionModel = new Gtk.SingleSelection(this.stringList);
29
+ this.sortListModel = new Gtk.SortListModel(this.stringList, this.widget.getSorter());
30
+ this.sortListModel.setIncremental(true);
31
+ this.selectionModel = new Gtk.SingleSelection(this.sortListModel);
20
32
  this.widget.setModel(this.selectionModel);
33
+ this.sortColumn = props.sortColumn ?? null;
34
+ this.sortOrder = props.sortOrder ?? Gtk.SortType.ASCENDING;
35
+ this.sortFn = props.sortFn ?? null;
36
+ this.onSortChange =
37
+ props.onSortChange ?? null;
38
+ this.connectSorterChangedSignal();
39
+ }
40
+ connectSorterChangedSignal() {
41
+ const sorter = this.widget.getSorter();
42
+ if (!sorter || !this.onSortChange)
43
+ return;
44
+ this.sorterChangedHandlerId = sorter.connect("changed", () => {
45
+ this.waitForSortComplete(() => this.notifySortChange());
46
+ });
47
+ }
48
+ waitForSortComplete(callback) {
49
+ const pending = this.sortListModel.getPending();
50
+ if (pending === 0) {
51
+ callback();
52
+ }
53
+ else {
54
+ // Sorting still in progress, check again after a short delay
55
+ setTimeout(() => this.waitForSortComplete(callback), 10);
56
+ }
57
+ }
58
+ disconnectSorterChangedSignal() {
59
+ if (this.sorterChangedHandlerId === null)
60
+ return;
61
+ const sorter = this.widget.getSorter();
62
+ if (sorter) {
63
+ GObject.signalHandlerDisconnect(sorter, this.sorterChangedHandlerId);
64
+ }
65
+ this.sorterChangedHandlerId = null;
66
+ }
67
+ notifySortChange() {
68
+ if (!this.onSortChange)
69
+ return;
70
+ const baseSorter = this.widget.getSorter();
71
+ if (!baseSorter)
72
+ return;
73
+ const sorter = getObject(baseSorter.ptr, Gtk.ColumnViewSorter);
74
+ const column = sorter.getPrimarySortColumn();
75
+ const order = sorter.getPrimarySortOrder();
76
+ const columnId = column?.getId() ?? null;
77
+ // Deduplicate: only notify if the sort state actually changed
78
+ if (columnId === this.lastNotifiedColumn && order === this.lastNotifiedOrder) {
79
+ return;
80
+ }
81
+ this.lastNotifiedColumn = columnId;
82
+ this.lastNotifiedOrder = order;
83
+ this.onSortChange(columnId, order);
21
84
  }
22
85
  getItems() {
23
86
  return this.items;
24
87
  }
25
- addColumn(column) {
26
- this.columns.push(column);
27
- const gtkColumn = column.getGtkColumn();
28
- this.widget.appendColumn(gtkColumn);
29
- column.setColumnView(this);
88
+ getSortFn() {
89
+ return this.sortFn;
90
+ }
91
+ compareItems(a, b, columnId) {
92
+ if (this.isSorting || !this.sortFn)
93
+ return 0;
94
+ this.isSorting = true;
95
+ try {
96
+ return this.sortFn(a, b, columnId);
97
+ }
98
+ finally {
99
+ this.isSorting = false;
100
+ }
101
+ }
102
+ addColumn(columnNode) {
103
+ this.columns.push(columnNode);
104
+ const column = columnNode.getColumn();
105
+ this.widget.appendColumn(column);
106
+ columnNode.setColumnView(this);
107
+ if (columnNode.getId() === this.sortColumn && this.sortColumn !== null) {
108
+ this.applySortByColumn();
109
+ }
110
+ }
111
+ applySortByColumn() {
112
+ if (this.sortColumn === null) {
113
+ this.widget.sortByColumn(this.sortOrder, null);
114
+ return;
115
+ }
116
+ if (!this.columns)
117
+ return;
118
+ const column = this.columns.find((c) => c.getId() === this.sortColumn);
119
+ if (column) {
120
+ this.widget.sortByColumn(this.sortOrder, column.getColumn());
121
+ }
122
+ }
123
+ findColumnById(id) {
124
+ return this.columns.find((c) => c.getId() === id);
30
125
  }
31
126
  removeColumn(column) {
32
127
  const index = this.columns.indexOf(column);
33
128
  if (index !== -1) {
34
129
  this.columns.splice(index, 1);
35
- this.widget.removeColumn(column.getGtkColumn());
130
+ this.widget.removeColumn(column.getColumn());
36
131
  column.setColumnView(null);
37
132
  }
38
133
  }
@@ -43,15 +138,16 @@ export class ColumnViewNode extends Node {
43
138
  return;
44
139
  }
45
140
  this.columns.splice(beforeIndex, 0, column);
46
- this.widget.insertColumn(beforeIndex, column.getGtkColumn());
141
+ this.widget.insertColumn(beforeIndex, column.getColumn());
47
142
  column.setColumnView(this);
48
143
  }
49
144
  syncStringList = () => {
50
145
  const newLength = this.items.length;
51
146
  if (newLength === this.committedLength)
52
147
  return;
53
- const placeholders = Array.from({ length: newLength }, () => "");
54
- this.stringList.splice(0, this.committedLength, placeholders);
148
+ // Store indices as strings so we can map back to items in the sorter
149
+ const indices = Array.from({ length: newLength }, (_, i) => String(i));
150
+ this.stringList.splice(0, this.committedLength, indices);
55
151
  this.committedLength = newLength;
56
152
  };
57
153
  addItem(item) {
@@ -75,6 +171,46 @@ export class ColumnViewNode extends Node {
75
171
  scheduleFlush(this.syncStringList);
76
172
  }
77
173
  }
174
+ consumedProps() {
175
+ const consumed = super.consumedProps();
176
+ consumed.add("sortColumn");
177
+ consumed.add("sortOrder");
178
+ consumed.add("onSortChange");
179
+ consumed.add("sortFn");
180
+ return consumed;
181
+ }
182
+ updateProps(oldProps, newProps) {
183
+ super.updateProps(oldProps, newProps);
184
+ const newSortColumn = newProps.sortColumn ?? null;
185
+ const newSortOrder = newProps.sortOrder ?? Gtk.SortType.ASCENDING;
186
+ const newSortFn = newProps.sortFn ?? null;
187
+ const newOnSortChange = newProps.onSortChange ?? null;
188
+ if (oldProps.onSortChange !== newProps.onSortChange) {
189
+ const hadCallback = this.onSortChange !== null;
190
+ this.onSortChange = newOnSortChange;
191
+ const hasCallback = this.onSortChange !== null;
192
+ // Connect or disconnect the signal handler as needed
193
+ if (!hadCallback && hasCallback) {
194
+ this.connectSorterChangedSignal();
195
+ }
196
+ else if (hadCallback && !hasCallback) {
197
+ this.disconnectSorterChangedSignal();
198
+ }
199
+ }
200
+ if (oldProps.sortFn !== newProps.sortFn) {
201
+ this.sortFn = newSortFn;
202
+ if (this.columns) {
203
+ for (const column of this.columns) {
204
+ column.updateSorterFromRoot();
205
+ }
206
+ }
207
+ }
208
+ if (oldProps.sortColumn !== newProps.sortColumn || oldProps.sortOrder !== newProps.sortOrder) {
209
+ this.sortColumn = newSortColumn;
210
+ this.sortOrder = newSortOrder;
211
+ this.applySortByColumn();
212
+ }
213
+ }
78
214
  }
79
215
  export class ColumnViewColumnNode extends Node {
80
216
  static matches(type) {
@@ -83,24 +219,30 @@ export class ColumnViewColumnNode extends Node {
83
219
  isVirtual() {
84
220
  return true;
85
221
  }
86
- gtkColumn;
222
+ column;
87
223
  factory;
88
224
  renderCell;
89
225
  columnView = null;
90
226
  listItemCache = new Map();
227
+ columnId = null;
228
+ sorter = null;
91
229
  constructor(type, props, app) {
92
230
  super(type, props, app);
93
231
  this.factory = new Gtk.SignalListItemFactory();
94
- this.gtkColumn = new Gtk.ColumnViewColumn(props.title, this.factory);
232
+ this.column = new Gtk.ColumnViewColumn(props.title, this.factory);
95
233
  this.renderCell = props.renderCell;
234
+ this.columnId = props.id ?? null;
235
+ if (this.columnId !== null) {
236
+ this.column.setId(this.columnId);
237
+ }
96
238
  if (props.expand !== undefined) {
97
- this.gtkColumn.setExpand(props.expand);
239
+ this.column.setExpand(props.expand);
98
240
  }
99
241
  if (props.resizable !== undefined) {
100
- this.gtkColumn.setResizable(props.resizable);
242
+ this.column.setResizable(props.resizable);
101
243
  }
102
244
  if (props.fixedWidth !== undefined) {
103
- this.gtkColumn.setFixedWidth(props.fixedWidth);
245
+ this.column.setFixedWidth(props.fixedWidth);
104
246
  }
105
247
  this.factory.connect("setup", (_self, listItemObj) => {
106
248
  const listItem = getObject(listItemObj.ptr, Gtk.ListItem);
@@ -142,11 +284,48 @@ export class ColumnViewColumnNode extends Node {
142
284
  }
143
285
  });
144
286
  }
145
- getGtkColumn() {
146
- return this.gtkColumn;
287
+ getColumn() {
288
+ return this.column;
289
+ }
290
+ getId() {
291
+ return this.columnId;
147
292
  }
148
293
  setColumnView(columnView) {
149
294
  this.columnView = columnView;
295
+ this.updateSorterFromRoot();
296
+ }
297
+ updateSorterFromRoot() {
298
+ if (!this.columnView || this.columnId === null) {
299
+ this.column.setSorter(null);
300
+ this.sorter = null;
301
+ return;
302
+ }
303
+ const rootSortFn = this.columnView.getSortFn();
304
+ if (rootSortFn === null) {
305
+ this.column.setSorter(null);
306
+ this.sorter = null;
307
+ return;
308
+ }
309
+ const columnId = this.columnId;
310
+ const columnView = this.columnView;
311
+ const wrappedSortFn = (_a, _b) => {
312
+ const items = columnView.getItems();
313
+ // _a and _b are GtkStringObject pointers - get the string content (indices)
314
+ const stringObjA = getObject(_a, Gtk.StringObject);
315
+ const stringObjB = getObject(_b, Gtk.StringObject);
316
+ const indexA = Number.parseInt(stringObjA.getString(), 10);
317
+ const indexB = Number.parseInt(stringObjB.getString(), 10);
318
+ if (Number.isNaN(indexA) || Number.isNaN(indexB))
319
+ return 0;
320
+ const itemA = items[indexA] ?? null;
321
+ const itemB = items[indexB] ?? null;
322
+ if (itemA === null || itemB === null)
323
+ return 0;
324
+ const result = columnView.compareItems(itemA, itemB, columnId);
325
+ return typeof result === "number" ? result : 0;
326
+ };
327
+ this.sorter = new Gtk.CustomSorter(wrappedSortFn);
328
+ this.column.setSorter(this.sorter);
150
329
  }
151
330
  attachToParent(parent) {
152
331
  if (parent instanceof ColumnViewNode) {
@@ -173,25 +352,30 @@ export class ColumnViewColumnNode extends Node {
173
352
  consumed.add("expand");
174
353
  consumed.add("resizable");
175
354
  consumed.add("fixedWidth");
355
+ consumed.add("id");
176
356
  return consumed;
177
357
  }
178
358
  updateProps(oldProps, newProps) {
179
359
  if (oldProps.renderCell !== newProps.renderCell) {
180
360
  this.renderCell = newProps.renderCell;
181
361
  }
182
- if (!this.gtkColumn)
362
+ if (!this.column)
183
363
  return;
184
364
  if (oldProps.title !== newProps.title) {
185
- this.gtkColumn.setTitle(newProps.title);
365
+ this.column.setTitle(newProps.title);
186
366
  }
187
367
  if (oldProps.expand !== newProps.expand) {
188
- this.gtkColumn.setExpand(newProps.expand);
368
+ this.column.setExpand(newProps.expand);
189
369
  }
190
370
  if (oldProps.resizable !== newProps.resizable) {
191
- this.gtkColumn.setResizable(newProps.resizable);
371
+ this.column.setResizable(newProps.resizable);
192
372
  }
193
373
  if (oldProps.fixedWidth !== newProps.fixedWidth) {
194
- this.gtkColumn.setFixedWidth(newProps.fixedWidth);
374
+ this.column.setFixedWidth(newProps.fixedWidth);
375
+ }
376
+ if (oldProps.id !== newProps.id) {
377
+ this.columnId = newProps.id ?? null;
378
+ this.column.setId(this.columnId);
195
379
  }
196
380
  }
197
381
  }
package/dist/types.d.ts CHANGED
@@ -1,4 +1,9 @@
1
+ import type { SortType } from "@gtkx/ffi/gtk";
1
2
  import type { ReactElement, ReactNode } from "react";
3
+ /**
4
+ * Props for slot components that accept children.
5
+ * Used by container widgets that render child elements in designated slots.
6
+ */
2
7
  export interface SlotProps {
3
8
  children?: ReactNode;
4
9
  }
@@ -19,11 +24,20 @@ export type RenderItemFn<T> = (item: T | null) => ReactElement;
19
24
  export interface ListViewRenderProps<T = unknown> {
20
25
  renderItem: RenderItemFn<T>;
21
26
  }
27
+ /**
28
+ * Comparison function for sorting items by column.
29
+ * Returns negative if a < b, 0 if a === b, positive if a > b.
30
+ * @param a - First item to compare
31
+ * @param b - Second item to compare
32
+ * @param columnId - The ID of the column being sorted
33
+ */
34
+ export type ColumnSortFn<T, C extends string = string> = (a: T, b: T, columnId: C) => number;
22
35
  export interface ColumnViewColumnProps {
23
36
  title?: string;
24
37
  expand?: boolean;
25
38
  resizable?: boolean;
26
39
  fixedWidth?: number;
40
+ id?: string;
27
41
  /**
28
42
  * Render function for column cells.
29
43
  * Called with null during setup (for loading state) and with the actual item during bind.
@@ -31,6 +45,12 @@ export interface ColumnViewColumnProps {
31
45
  */
32
46
  renderCell: (item: any) => ReactElement;
33
47
  }
48
+ export interface ColumnViewRootProps<C extends string = string> {
49
+ sortColumn?: C | null;
50
+ sortOrder?: SortType;
51
+ onSortChange?: (column: C | null, order: SortType) => void;
52
+ sortFn?: ColumnSortFn<any, C>;
53
+ }
34
54
  export interface NotebookPageProps extends SlotProps {
35
55
  label: string;
36
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtkx/react",
3
- "version": "0.1.47",
3
+ "version": "0.1.48",
4
4
  "description": "Build GTK4 desktop applications with React and TypeScript",
5
5
  "keywords": [
6
6
  "gtk",
@@ -36,10 +36,10 @@
36
36
  ],
37
37
  "dependencies": {
38
38
  "react-reconciler": "0.33.0",
39
- "@gtkx/ffi": "0.1.47"
39
+ "@gtkx/ffi": "0.1.48"
40
40
  },
41
41
  "devDependencies": {
42
- "@gtkx/gir": "0.1.47"
42
+ "@gtkx/gir": "0.1.48"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "react": "^19"