@gtkx/cli 0.9.4 → 0.10.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/create.js CHANGED
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import * as p from "@clack/prompts";
5
+ import { renderFile } from "./templates.js";
5
6
  const DEPENDENCIES = ["@gtkx/css", "@gtkx/ffi", "@gtkx/react", "react"];
6
7
  const DEV_DEPENDENCIES = ["@gtkx/cli", "@types/react", "typescript"];
7
8
  const TESTING_DEV_DEPENDENCIES = {
@@ -9,988 +10,12 @@ const TESTING_DEV_DEPENDENCIES = {
9
10
  jest: ["@gtkx/testing", "jest", "@types/jest", "ts-jest"],
10
11
  node: ["@gtkx/testing", "@types/node"],
11
12
  };
12
- export const getTestScript = (testing) => {
13
- const env = "GDK_BACKEND=x11 GSK_RENDERER=cairo LIBGL_ALWAYS_SOFTWARE=1";
14
- switch (testing) {
15
- case "vitest":
16
- return `${env} xvfb-run -a vitest`;
17
- case "jest":
18
- return `${env} xvfb-run -a jest`;
19
- case "node":
20
- return `${env} xvfb-run -a node --import tsx --test tests/**/*.test.ts`;
21
- case "none":
22
- return undefined;
23
- }
24
- };
25
- export const generatePackageJson = (name, appId, testing) => {
26
- const testScript = getTestScript(testing);
27
- const scripts = {
28
- dev: "gtkx dev src/app.tsx",
29
- build: "tsc -b",
30
- start: "node dist/index.js",
31
- };
32
- if (testScript) {
33
- scripts.test = testScript;
34
- }
35
- return JSON.stringify({
36
- name,
37
- version: "0.0.1",
38
- private: true,
39
- type: "module",
40
- scripts,
41
- gtkx: {
42
- appId,
43
- },
44
- }, null, 4);
45
- };
46
- export const generateTsConfig = () => {
47
- return JSON.stringify({
48
- compilerOptions: {
49
- target: "ESNext",
50
- module: "NodeNext",
51
- moduleResolution: "NodeNext",
52
- jsx: "react-jsx",
53
- strict: true,
54
- skipLibCheck: true,
55
- outDir: "dist",
56
- rootDir: "src",
57
- },
58
- include: ["src/**/*"],
59
- }, null, 4);
60
- };
61
- const generateAppTsx = (name, appId) => {
13
+ const createTemplateContext = (name, appId, testing) => {
62
14
  const title = name
63
15
  .split("-")
64
16
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
65
17
  .join(" ");
66
- return `import { useState } from "react";
67
- import * as Gtk from "@gtkx/ffi/gtk";
68
- import { ApplicationWindow, Box, Button, Label, quit } from "@gtkx/react";
69
-
70
- export default function App() {
71
- const [count, setCount] = useState(0);
72
-
73
- return (
74
- <ApplicationWindow title="${title}" defaultWidth={400} defaultHeight={300} onCloseRequest={quit}>
75
- <Box orientation={Gtk.Orientation.VERTICAL} spacing={20} marginTop={40} marginStart={40} marginEnd={40}>
76
- <Label label="Welcome to GTKX!" />
77
- <Label label={\`Count: \${count}\`} />
78
- <Button label="Increment" onClicked={() => setCount((c) => c + 1)} />
79
- </Box>
80
- </ApplicationWindow>
81
- );
82
- }
83
-
84
- export const appId = "${appId}";
85
- `;
86
- };
87
- const generateIndexTsx = () => {
88
- return `import { render } from "@gtkx/react";
89
- import App, { appId } from "./app.js";
90
-
91
- render(<App />, appId);
92
- `;
93
- };
94
- const generateGitignore = () => {
95
- return `node_modules/
96
- dist/
97
- *.log
98
- .DS_Store
99
- `;
100
- };
101
- const generateSkillMd = () => {
102
- return `---
103
- name: developing-gtkx-apps
104
- description: Build GTK4 desktop applications with GTKX React framework. Use when creating GTKX components, working with GTK widgets, handling signals, or building Linux desktop UIs with React.
105
- ---
106
-
107
- # Developing GTKX Applications
108
-
109
- GTKX is a React framework for building native GTK4 desktop applications on Linux. It uses a custom React reconciler to render React components as native GTK widgets.
110
-
111
- ## Quick Start
112
-
113
- \`\`\`tsx
114
- import { ApplicationWindow, render, quit } from "@gtkx/react";
115
- import * as Gtk from "@gtkx/ffi/gtk";
116
-
117
- const App = () => (
118
- <ApplicationWindow title="My App" defaultWidth={800} defaultHeight={600}>
119
- <Box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
120
- <Label label="Hello, GTKX!" />
121
- <Button label="Quit" onClicked={quit} />
122
- </Box>
123
- </ApplicationWindow>
124
- );
125
-
126
- render(<App />, "com.example.myapp");
127
- \`\`\`
128
-
129
- ## Widget Patterns
130
-
131
- ### Container Widgets
132
-
133
- **Box** - Linear layout:
134
- \`\`\`tsx
135
- <Box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
136
- <Label label="First" />
137
- <Label label="Second" />
138
- </Box>
139
- \`\`\`
140
-
141
- **Grid** - 2D positioning:
142
- \`\`\`tsx
143
- <Grid.Root spacing={10}>
144
- <Grid.Child column={0} row={0}>
145
- <Label label="Top-left" />
146
- </Grid.Child>
147
- <Grid.Child column={1} row={0} columnSpan={2}>
148
- <Label label="Spans 2 columns" />
149
- </Grid.Child>
150
- </Grid.Root>
151
- \`\`\`
152
-
153
- **Stack** - Page-based container:
154
- \`\`\`tsx
155
- <Stack.Root visibleChildName="page1">
156
- <Stack.Page name="page1" title="Page 1">
157
- <Label label="Content 1" />
158
- </Stack.Page>
159
- <Stack.Page name="page2" title="Page 2">
160
- <Label label="Content 2" />
161
- </Stack.Page>
162
- </Stack.Root>
163
- \`\`\`
164
-
165
- **Notebook** - Tabbed container:
166
- \`\`\`tsx
167
- <Notebook.Root>
168
- <Notebook.Page label="Tab 1">
169
- <Content1 />
170
- </Notebook.Page>
171
- <Notebook.Page label="Tab 2">
172
- <Content2 />
173
- </Notebook.Page>
174
- </Notebook.Root>
175
- \`\`\`
176
-
177
- **Paned** - Resizable split:
178
- \`\`\`tsx
179
- <Paned.Root orientation={Gtk.Orientation.HORIZONTAL} position={280}>
180
- <Paned.StartChild>
181
- <SideBar />
182
- </Paned.StartChild>
183
- <Paned.EndChild>
184
- <MainContent />
185
- </Paned.EndChild>
186
- </Paned.Root>
187
- \`\`\`
188
-
189
- ### Virtual Scrolling Lists
190
-
191
- **ListView** - High-performance scrollable list with selection:
192
- \`\`\`tsx
193
- <ListView.Root
194
- vexpand
195
- selected={[selectedId]}
196
- selectionMode={Gtk.SelectionMode.SINGLE}
197
- onSelectionChanged={(ids) => setSelectedId(ids[0])}
198
- renderItem={(item: Item | null) => (
199
- <Label label={item?.text ?? ""} />
200
- )}
201
- >
202
- {items.map(item => (
203
- <ListView.Item key={item.id} id={item.id} item={item} />
204
- ))}
205
- </ListView.Root>
206
- \`\`\`
207
-
208
- **GridView** - Grid-based virtual scrolling:
209
- \`\`\`tsx
210
- <GridView.Root
211
- vexpand
212
- renderItem={(item: Item | null) => (
213
- <Box orientation={Gtk.Orientation.VERTICAL}>
214
- <Image iconName={item?.icon ?? "image-missing"} />
215
- <Label label={item?.name ?? ""} />
216
- </Box>
217
- )}
218
- >
219
- {items.map(item => (
220
- <GridView.Item key={item.id} id={item.id} item={item} />
221
- ))}
222
- </GridView.Root>
223
- \`\`\`
224
-
225
- **ColumnView** - Table with sortable columns:
226
- \`\`\`tsx
227
- <ColumnView.Root
228
- sortColumn="name"
229
- sortOrder={Gtk.SortType.ASCENDING}
230
- onSortChange={handleSort}
231
- >
232
- <ColumnView.Column
233
- title="Name"
234
- id="name"
235
- expand
236
- sortable
237
- renderCell={(item: Item | null) => (
238
- <Label label={item?.name ?? ""} />
239
- )}
240
- />
241
- {items.map(item => (
242
- <ColumnView.Item key={item.id} id={item.id} item={item} />
243
- ))}
244
- </ColumnView.Root>
245
- \`\`\`
246
-
247
- **DropDown** - String selection widget:
248
- \`\`\`tsx
249
- <DropDown.Root>
250
- {options.map(opt => (
251
- <DropDown.Item key={opt.value} id={opt.value} label={opt.label} />
252
- ))}
253
- </DropDown.Root>
254
- \`\`\`
255
-
256
- ### HeaderBar
257
-
258
- Pack widgets at start and end of the title bar:
259
- \`\`\`tsx
260
- <HeaderBar.Root>
261
- <HeaderBar.Start>
262
- <Button iconName="go-previous-symbolic" />
263
- </HeaderBar.Start>
264
- <HeaderBar.End>
265
- <MenuButton.Root iconName="open-menu-symbolic" />
266
- </HeaderBar.End>
267
- </HeaderBar.Root>
268
- \`\`\`
269
-
270
- ### ActionBar
271
-
272
- Bottom bar with packed widgets:
273
- \`\`\`tsx
274
- <ActionBar.Root>
275
- <ActionBar.Start>
276
- <Button label="Cancel" />
277
- </ActionBar.Start>
278
- <ActionBar.End>
279
- <Button label="Save" cssClasses={["suggested-action"]} />
280
- </ActionBar.End>
281
- </ActionBar.Root>
282
- \`\`\`
283
-
284
- ### Controlled Input
285
-
286
- Entry requires two-way binding:
287
- \`\`\`tsx
288
- const [text, setText] = useState("");
289
-
290
- <Entry
291
- text={text}
292
- onChanged={(entry) => setText(entry.getText())}
293
- placeholder="Type here..."
294
- />
295
- \`\`\`
296
-
297
- ### Declarative Menus
298
-
299
- \`\`\`tsx
300
- <ApplicationMenu>
301
- <Menu.Submenu label="File">
302
- <Menu.Item
303
- label="New"
304
- onActivate={handleNew}
305
- accels="<Control>n"
306
- />
307
- <Menu.Section>
308
- <Menu.Item label="Quit" onActivate={quit} accels="<Control>q" />
309
- </Menu.Section>
310
- </Menu.Submenu>
311
- </ApplicationMenu>
312
- \`\`\`
313
-
314
- ## Signal Handling
315
-
316
- GTK signals map to \`on<SignalName>\` props:
317
- - \`clicked\` → \`onClicked\`
318
- - \`toggled\` → \`onToggled\`
319
- - \`changed\` → \`onChanged\`
320
- - \`notify::selected\` → \`onNotifySelected\`
321
-
322
- ## Widget References
323
-
324
- \`\`\`tsx
325
- import { useRef } from "react";
326
-
327
- const entryRef = useRef<Gtk.Entry | null>(null);
328
- <Entry ref={entryRef} />
329
- // Later: entryRef.current?.getText()
330
- \`\`\`
331
-
332
- ## Portals
333
-
334
- \`\`\`tsx
335
- import { createPortal } from "@gtkx/react";
336
-
337
- {createPortal(<AboutDialog programName="My App" />)}
338
- \`\`\`
339
-
340
- ## Constraints
341
-
342
- - **GTK is single-threaded**: All widget operations on main thread
343
- - **Virtual lists need immutable data**: Use stable object references
344
- - **ToggleButton auto-prevents feedback loops**: Safe for controlled state
345
- - **Entry needs two-way binding**: Use \`onChanged\` to sync state
346
-
347
- For detailed widget reference, see [WIDGETS.md](WIDGETS.md).
348
- For code examples, see [EXAMPLES.md](EXAMPLES.md).
349
- `;
350
- };
351
- const generateWidgetsMd = () => {
352
- return `# GTKX Widget Reference
353
-
354
- ## Container Widgets
355
-
356
- ### Box
357
- Linear layout container.
358
-
359
- \`\`\`tsx
360
- <Box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
361
- <Label label="Child 1" />
362
- <Label label="Child 2" />
363
- </Box>
364
- \`\`\`
365
-
366
- Props:
367
- - \`orientation\`: \`Gtk.Orientation.HORIZONTAL\` | \`Gtk.Orientation.VERTICAL\`
368
- - \`spacing\`: number (pixels between children)
369
- - \`homogeneous\`: boolean (equal child sizes)
370
-
371
- ### Grid
372
- 2D grid layout with explicit positioning.
373
-
374
- \`\`\`tsx
375
- <Grid.Root spacing={10} rowSpacing={5} columnSpacing={5}>
376
- <Grid.Child column={0} row={0}>
377
- <Label label="Top-left" />
378
- </Grid.Child>
379
- <Grid.Child column={1} row={0} columnSpan={2}>
380
- <Label label="Spans 2 columns" />
381
- </Grid.Child>
382
- </Grid.Root>
383
- \`\`\`
384
-
385
- Grid.Child props (consumed, not passed to GTK):
386
- - \`column\`: number (0-indexed)
387
- - \`row\`: number (0-indexed)
388
- - \`columnSpan\`: number (default 1)
389
- - \`rowSpan\`: number (default 1)
390
-
391
- ### Stack
392
- Shows one child at a time, switchable by name.
393
-
394
- \`\`\`tsx
395
- <Stack.Root visibleChildName="page1">
396
- <Stack.Page name="page1" title="First" iconName="document-new">
397
- <Content1 />
398
- </Stack.Page>
399
- <Stack.Page name="page2" title="Second">
400
- <Content2 />
401
- </Stack.Page>
402
- </Stack.Root>
403
- \`\`\`
404
-
405
- Stack.Page props (consumed):
406
- - \`name\`: string (required, unique identifier)
407
- - \`title\`: string (display title)
408
- - \`iconName\`: string (icon name)
409
-
410
- ### Notebook
411
- Tabbed container with visible tabs.
412
-
413
- \`\`\`tsx
414
- <Notebook.Root>
415
- <Notebook.Page label="Tab 1">
416
- <Content1 />
417
- </Notebook.Page>
418
- <Notebook.Page label="Tab 2">
419
- <Content2 />
420
- </Notebook.Page>
421
- </Notebook.Root>
422
- \`\`\`
423
-
424
- Notebook.Page props (consumed):
425
- - \`label\`: string (tab label)
426
-
427
- ### Paned
428
- Resizable split container with draggable divider.
429
-
430
- \`\`\`tsx
431
- <Paned.Root
432
- orientation={Gtk.Orientation.HORIZONTAL}
433
- position={280}
434
- shrinkStartChild={false}
435
- shrinkEndChild={false}
436
- >
437
- <Paned.StartChild>
438
- <SidePanel />
439
- </Paned.StartChild>
440
- <Paned.EndChild>
441
- <MainContent />
442
- </Paned.EndChild>
443
- </Paned.Root>
444
- \`\`\`
445
-
446
- Props:
447
- - \`orientation\`: \`Gtk.Orientation.HORIZONTAL\` | \`Gtk.Orientation.VERTICAL\`
448
- - \`position\`: number (divider position in pixels)
449
- - \`shrinkStartChild\`: boolean
450
- - \`shrinkEndChild\`: boolean
451
-
452
- ## Virtual Scrolling Widgets
453
-
454
- ### ListView
455
- High-performance scrollable list with virtual rendering and selection support.
456
-
457
- \`\`\`tsx
458
- <ListView.Root
459
- vexpand
460
- selected={[selectedId]}
461
- selectionMode={Gtk.SelectionMode.SINGLE}
462
- onSelectionChanged={(ids) => setSelectedId(ids[0])}
463
- renderItem={(item: Item | null) => (
464
- <Label label={item?.text ?? ""} />
465
- )}
466
- >
467
- {items.map(item => (
468
- <ListView.Item key={item.id} id={item.id} item={item} />
469
- ))}
470
- </ListView.Root>
471
- \`\`\`
472
-
473
- Props:
474
- - \`renderItem\`: \`(item: T | null) => ReactElement\` (required)
475
- - \`selected\`: string[] (array of selected item IDs)
476
- - \`selectionMode\`: \`Gtk.SelectionMode.SINGLE\` | \`MULTIPLE\` | \`NONE\`
477
- - \`onSelectionChanged\`: \`(ids: string[]) => void\`
478
-
479
- ListView.Item props:
480
- - \`id\`: string (required, unique identifier for selection)
481
- - \`item\`: T (the data item)
482
-
483
- ### GridView
484
- Grid-based virtual scrolling. Same API as ListView but renders items in a grid.
485
-
486
- \`\`\`tsx
487
- <GridView.Root
488
- vexpand
489
- renderItem={(item: Item | null) => (
490
- <Box orientation={Gtk.Orientation.VERTICAL}>
491
- <Image iconName={item?.icon ?? "image-missing"} />
492
- <Label label={item?.name ?? ""} />
493
- </Box>
494
- )}
495
- >
496
- {items.map(item => (
497
- <GridView.Item key={item.id} id={item.id} item={item} />
498
- ))}
499
- </GridView.Root>
500
- \`\`\`
501
-
502
- ### ColumnView
503
- Table with sortable columns.
504
-
505
- \`\`\`tsx
506
- <ColumnView.Root
507
- sortColumn="name"
508
- sortOrder={Gtk.SortType.ASCENDING}
509
- onSortChange={(column, order) => handleSort(column, order)}
510
- >
511
- <ColumnView.Column
512
- title="Name"
513
- id="name"
514
- expand
515
- resizable
516
- sortable
517
- renderCell={(item: Item | null) => (
518
- <Label label={item?.name ?? ""} />
519
- )}
520
- />
521
- {items.map(item => (
522
- <ColumnView.Item key={item.id} id={item.id} item={item} />
523
- ))}
524
- </ColumnView.Root>
525
- \`\`\`
526
-
527
- ColumnView.Column props:
528
- - \`title\`: string (column header)
529
- - \`id\`: string (used for sorting)
530
- - \`expand\`: boolean (fill available space)
531
- - \`resizable\`: boolean (user can resize)
532
- - \`sortable\`: boolean (clicking header triggers sort)
533
- - \`fixedWidth\`: number (fixed width in pixels)
534
- - \`renderCell\`: \`(item: T | null) => ReactElement\`
535
-
536
- ### DropDown
537
- String selection dropdown.
538
-
539
- \`\`\`tsx
540
- <DropDown.Root>
541
- {options.map(opt => (
542
- <DropDown.Item key={opt.value} id={opt.value} label={opt.label} />
543
- ))}
544
- </DropDown.Root>
545
- \`\`\`
546
-
547
- DropDown.Item props:
548
- - \`id\`: string (unique identifier)
549
- - \`label\`: string (display text)
550
-
551
- ## Header Widgets
552
-
553
- ### HeaderBar
554
- Title bar with packed widgets at start and end.
555
-
556
- \`\`\`tsx
557
- <HeaderBar.Root>
558
- <HeaderBar.Start>
559
- <Button iconName="go-previous-symbolic" />
560
- </HeaderBar.Start>
561
- <HeaderBar.End>
562
- <MenuButton.Root iconName="open-menu-symbolic" />
563
- </HeaderBar.End>
564
- </HeaderBar.Root>
565
- \`\`\`
566
-
567
- ### ActionBar
568
- Bottom action bar with start/end packing.
569
-
570
- \`\`\`tsx
571
- <ActionBar.Root>
572
- <ActionBar.Start>
573
- <Button label="Cancel" />
574
- </ActionBar.Start>
575
- <ActionBar.End>
576
- <Button label="Save" cssClasses={["suggested-action"]} />
577
- </ActionBar.End>
578
- </ActionBar.Root>
579
- \`\`\`
580
-
581
- ## Input Widgets
582
-
583
- ### Entry
584
- Single-line text input. Requires two-way binding for controlled behavior.
585
-
586
- \`\`\`tsx
587
- const [text, setText] = useState("");
588
-
589
- <Entry
590
- text={text}
591
- onChanged={(entry) => setText(entry.getText())}
592
- placeholder="Enter text..."
593
- />
594
- \`\`\`
595
-
596
- ### ToggleButton
597
- Toggle button with controlled state. Auto-prevents signal feedback loops.
598
-
599
- \`\`\`tsx
600
- const [active, setActive] = useState(false);
601
-
602
- <ToggleButton.Root
603
- active={active}
604
- onToggled={() => setActive(!active)}
605
- label="Toggle me"
606
- />
607
- \`\`\`
608
-
609
- ## Display Widgets
610
-
611
- ### Label
612
- \`\`\`tsx
613
- <Label label="Hello World" halign={Gtk.Align.START} wrap useMarkup />
614
- \`\`\`
615
-
616
- ### Button
617
- \`\`\`tsx
618
- <Button label="Click me" onClicked={() => handleClick()} iconName="document-new" />
619
- \`\`\`
620
-
621
- ### MenuButton
622
- \`\`\`tsx
623
- <MenuButton.Root label="Options" iconName="open-menu">
624
- <MenuButton.Popover>
625
- <PopoverMenu.Root>
626
- <Menu.Item label="Action" onActivate={handle} />
627
- </PopoverMenu.Root>
628
- </MenuButton.Popover>
629
- </MenuButton.Root>
630
- \`\`\`
631
-
632
- ## Menu Widgets
633
-
634
- ### ApplicationMenu
635
- \`\`\`tsx
636
- <ApplicationMenu>
637
- <Menu.Submenu label="File">
638
- <Menu.Item label="New" onActivate={handleNew} accels="<Control>n" />
639
- <Menu.Section>
640
- <Menu.Item label="Quit" onActivate={quit} accels="<Control>q" />
641
- </Menu.Section>
642
- </Menu.Submenu>
643
- </ApplicationMenu>
644
- \`\`\`
645
-
646
- ### Menu.Item
647
- Props:
648
- - \`label\`: string
649
- - \`onActivate\`: \`() => void\`
650
- - \`accels\`: string | string[] (e.g., "<Control>n")
651
-
652
- ### Menu.Section
653
- Groups menu items with optional label.
654
-
655
- ### Menu.Submenu
656
- Nested submenu.
657
-
658
- ## Window Widgets
659
-
660
- ### ApplicationWindow
661
- \`\`\`tsx
662
- <ApplicationWindow
663
- title="My App"
664
- defaultWidth={800}
665
- defaultHeight={600}
666
- showMenubar
667
- onCloseRequest={() => false}
668
- >
669
- <MainContent />
670
- </ApplicationWindow>
671
- \`\`\`
672
-
673
- ## Common Props
674
-
675
- All widgets support:
676
- - \`hexpand\` / \`vexpand\`: boolean (expand to fill space)
677
- - \`halign\` / \`valign\`: \`Gtk.Align.START\` | \`CENTER\` | \`END\` | \`FILL\`
678
- - \`marginStart\` / \`marginEnd\` / \`marginTop\` / \`marginBottom\`: number
679
- - \`sensitive\`: boolean (enabled/disabled)
680
- - \`visible\`: boolean
681
- - \`cssClasses\`: string[]
682
- `;
683
- };
684
- const generateExamplesMd = () => {
685
- return `# GTKX Code Examples
686
-
687
- ## Application Structure
688
-
689
- ### Basic App with State
690
-
691
- \`\`\`tsx
692
- import * as Gtk from "@gtkx/ffi/gtk";
693
- import { ApplicationWindow, Box, Label, quit } from "@gtkx/react";
694
- import { useCallback, useState } from "react";
695
-
696
- interface Todo {
697
- id: number;
698
- text: string;
699
- completed: boolean;
700
- }
701
-
702
- let nextId = 1;
703
-
704
- export const App = () => {
705
- const [todos, setTodos] = useState<Todo[]>([]);
706
-
707
- const addTodo = useCallback((text: string) => {
708
- setTodos((prev) => [...prev, { id: nextId++, text, completed: false }]);
709
- }, []);
710
-
711
- const toggleTodo = useCallback((id: number) => {
712
- setTodos((prev) =>
713
- prev.map((todo) =>
714
- todo.id === id ? { ...todo, completed: !todo.completed } : todo
715
- )
716
- );
717
- }, []);
718
-
719
- return (
720
- <ApplicationWindow title="Todo App" defaultWidth={400} defaultHeight={500} onCloseRequest={quit}>
721
- <Box orientation={Gtk.Orientation.VERTICAL} spacing={16} marginTop={16} marginStart={16} marginEnd={16}>
722
- <Label label="Todo App" />
723
- </Box>
724
- </ApplicationWindow>
725
- );
726
- };
727
-
728
- export const appId = "com.gtkx.todo";
729
- \`\`\`
730
-
731
- ## Layout Patterns
732
-
733
- ### Grid for Forms
734
-
735
- \`\`\`tsx
736
- import * as Gtk from "@gtkx/ffi/gtk";
737
- import { Button, Entry, Grid, Label } from "@gtkx/react";
738
- import { useState } from "react";
739
-
740
- const FormLayout = () => {
741
- const [name, setName] = useState("");
742
- const [email, setEmail] = useState("");
743
-
744
- return (
745
- <Grid.Root rowSpacing={8} columnSpacing={12}>
746
- <Grid.Child column={0} row={0}>
747
- <Label label="Name:" halign={Gtk.Align.END} />
748
- </Grid.Child>
749
- <Grid.Child column={1} row={0}>
750
- <Entry text={name} onChanged={(e) => setName(e.getText())} hexpand />
751
- </Grid.Child>
752
- <Grid.Child column={0} row={1}>
753
- <Label label="Email:" halign={Gtk.Align.END} />
754
- </Grid.Child>
755
- <Grid.Child column={1} row={1}>
756
- <Entry text={email} onChanged={(e) => setEmail(e.getText())} hexpand />
757
- </Grid.Child>
758
- <Grid.Child column={0} row={2} columnSpan={2}>
759
- <Button label="Submit" halign={Gtk.Align.END} marginTop={8} />
760
- </Grid.Child>
761
- </Grid.Root>
762
- );
763
- };
764
- \`\`\`
765
-
766
- ### Stack with StackSwitcher
767
-
768
- \`\`\`tsx
769
- import * as Gtk from "@gtkx/ffi/gtk";
770
- import { Box, Label, Stack, StackSwitcher } from "@gtkx/react";
771
-
772
- const TabContainer = () => (
773
- <Box orientation={Gtk.Orientation.VERTICAL} spacing={8}>
774
- <StackSwitcher.Root
775
- ref={(switcher: Gtk.StackSwitcher | null) => {
776
- if (switcher) {
777
- const stack = switcher.getParent()?.getLastChild() as Gtk.Stack | null;
778
- if (stack) switcher.setStack(stack);
779
- }
780
- }}
781
- />
782
- <Stack.Root transitionType={Gtk.StackTransitionType.SLIDE_LEFT_RIGHT} transitionDuration={200}>
783
- <Stack.Page name="page1" title="First">
784
- <Label label="First Page Content" />
785
- </Stack.Page>
786
- <Stack.Page name="page2" title="Second">
787
- <Label label="Second Page Content" />
788
- </Stack.Page>
789
- </Stack.Root>
790
- </Box>
791
- );
792
- \`\`\`
793
-
794
- ## Virtual Scrolling Lists
795
-
796
- ### ListView with Selection
797
-
798
- \`\`\`tsx
799
- import * as Gtk from "@gtkx/ffi/gtk";
800
- import { Box, Label, ListView } from "@gtkx/react";
801
- import { useState } from "react";
802
-
803
- interface Task {
804
- id: string;
805
- title: string;
806
- completed: boolean;
807
- }
808
-
809
- const tasks: Task[] = [
810
- { id: "1", title: "Learn GTK4", completed: true },
811
- { id: "2", title: "Build React app", completed: false },
812
- ];
813
-
814
- const TaskList = () => {
815
- const [selectedId, setSelectedId] = useState<string | undefined>();
816
-
817
- return (
818
- <Box cssClasses={["card"]} heightRequest={250}>
819
- <ListView.Root
820
- vexpand
821
- selected={selectedId ? [selectedId] : []}
822
- onSelectionChanged={(ids) => setSelectedId(ids[0])}
823
- renderItem={(task: Task | null) => (
824
- <Label
825
- label={task?.title ?? ""}
826
- cssClasses={task?.completed ? ["dim-label"] : []}
827
- halign={Gtk.Align.START}
828
- marginStart={12}
829
- marginTop={8}
830
- marginBottom={8}
831
- />
832
- )}
833
- >
834
- {tasks.map((task) => (
835
- <ListView.Item key={task.id} id={task.id} item={task} />
836
- ))}
837
- </ListView.Root>
838
- </Box>
839
- );
840
- };
841
- \`\`\`
842
-
843
- ### HeaderBar with Navigation
844
-
845
- \`\`\`tsx
846
- import * as Gtk from "@gtkx/ffi/gtk";
847
- import { ApplicationWindow, Box, Button, HeaderBar, Label, quit } from "@gtkx/react";
848
- import { useState } from "react";
849
-
850
- const AppWithHeaderBar = () => {
851
- const [page, setPage] = useState("home");
852
-
853
- return (
854
- <ApplicationWindow title="My App" defaultWidth={600} defaultHeight={400} onCloseRequest={quit}>
855
- <Box orientation={Gtk.Orientation.VERTICAL}>
856
- <HeaderBar.Root>
857
- <HeaderBar.Start>
858
- {page !== "home" && (
859
- <Button iconName="go-previous-symbolic" onClicked={() => setPage("home")} />
860
- )}
861
- </HeaderBar.Start>
862
- <HeaderBar.End>
863
- <Button iconName="emblem-system-symbolic" onClicked={() => setPage("settings")} />
864
- </HeaderBar.End>
865
- </HeaderBar.Root>
866
- <Label label={page === "home" ? "Home Page" : "Settings Page"} vexpand />
867
- </Box>
868
- </ApplicationWindow>
869
- );
870
- };
871
- \`\`\`
872
-
873
- ## Menus
874
-
875
- ### MenuButton with PopoverMenu
876
-
877
- \`\`\`tsx
878
- import * as Gtk from "@gtkx/ffi/gtk";
879
- import { Box, Label, Menu, MenuButton, PopoverMenu } from "@gtkx/react";
880
- import { useState } from "react";
881
-
882
- const MenuDemo = () => {
883
- const [lastAction, setLastAction] = useState<string | null>(null);
884
-
885
- return (
886
- <Box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
887
- <Label label={\`Last action: \${lastAction ?? "(none)"}\`} />
888
- <MenuButton.Root label="Actions">
889
- <MenuButton.Popover>
890
- <PopoverMenu.Root>
891
- <Menu.Item label="New" onActivate={() => setLastAction("New")} accels="<Control>n" />
892
- <Menu.Item label="Open" onActivate={() => setLastAction("Open")} accels="<Control>o" />
893
- <Menu.Item label="Save" onActivate={() => setLastAction("Save")} accels="<Control>s" />
894
- </PopoverMenu.Root>
895
- </MenuButton.Popover>
896
- </MenuButton.Root>
897
- </Box>
898
- );
899
- };
900
- \`\`\`
901
-
902
- ## Component Props Pattern
903
-
904
- ### List Item Component
905
-
906
- \`\`\`tsx
907
- import * as Gtk from "@gtkx/ffi/gtk";
908
- import { Box, Button, CheckButton, Label } from "@gtkx/react";
909
-
910
- interface Todo {
911
- id: number;
912
- text: string;
913
- completed: boolean;
914
- }
915
-
916
- interface TodoItemProps {
917
- todo: Todo;
918
- onToggle: (id: number) => void;
919
- onDelete: (id: number) => void;
920
- }
921
-
922
- export const TodoItem = ({ todo, onToggle, onDelete }: TodoItemProps) => (
923
- <Box orientation={Gtk.Orientation.HORIZONTAL} spacing={8}>
924
- <CheckButton active={todo.completed} onToggled={() => onToggle(todo.id)} />
925
- <Label label={todo.text} hexpand cssClasses={todo.completed ? ["dim-label"] : []} />
926
- <Button iconName="edit-delete-symbolic" onClicked={() => onDelete(todo.id)} cssClasses={["flat"]} />
927
- </Box>
928
- );
929
- \`\`\`
930
- `;
931
- };
932
- const generateExampleTest = (testing) => {
933
- const imports = testing === "vitest"
934
- ? `import { describe, it, expect, afterEach } from "vitest";`
935
- : testing === "jest"
936
- ? `import { describe, it, expect, afterEach } from "@jest/globals";`
937
- : `import { describe, it, after } from "node:test";
938
- import { strict as assert } from "node:assert";`;
939
- const afterEachFn = testing === "node" ? "after" : "afterEach";
940
- const assertion = testing === "node" ? `assert.ok(button, "Button should be rendered");` : `expect(button).toBeDefined();`;
941
- return `${imports}
942
- import * as Gtk from "@gtkx/ffi/gtk";
943
- import { cleanup, render, screen } from "@gtkx/testing";
944
- import App from "../src/app.js";
945
-
946
- ${afterEachFn}(async () => {
947
- await cleanup();
948
- });
949
-
950
- describe("App", () => {
951
- it("renders the increment button", async () => {
952
- await render(<App />, { wrapper: false });
953
- const button = await screen.findByRole(Gtk.AccessibleRole.BUTTON, { name: "Increment" });
954
- ${assertion}
955
- });
956
- });
957
- `;
958
- };
959
- const generateVitestConfig = () => {
960
- return `import { defineConfig } from "vitest/config";
961
-
962
- export default defineConfig({
963
- test: {
964
- include: ["tests/**/*.test.{ts,tsx}"],
965
- globals: false,
966
- },
967
- esbuild: {
968
- jsx: "automatic",
969
- },
970
- });
971
- `;
972
- };
973
- const generateJestConfig = () => {
974
- return `/** @type {import('jest').Config} */
975
- export default {
976
- preset: "ts-jest/presets/default-esm",
977
- testEnvironment: "node",
978
- testMatch: ["**/tests/**/*.test.ts"],
979
- extensionsToTreatAsEsm: [".ts", ".tsx"],
980
- moduleNameMapper: {
981
- "^(\\\\.{1,2}/.*)\\\\.js$": "$1",
982
- },
983
- transform: {
984
- "^.+\\\\.tsx?$": [
985
- "ts-jest",
986
- {
987
- useESM: true,
988
- tsconfig: "tsconfig.json",
989
- },
990
- ],
991
- },
992
- };
993
- `;
18
+ return { name, appId, title, testing };
994
19
  };
995
20
  export const getAddCommand = (pm, deps, dev) => {
996
21
  const devFlag = dev ? (pm === "npm" ? "--save-dev" : "-D") : "";
@@ -1056,7 +81,6 @@ const promptForOptions = async (options) => {
1056
81
  if (existsSync(resolve(process.cwd(), value))) {
1057
82
  return `Directory "${value}" already exists`;
1058
83
  }
1059
- return undefined;
1060
84
  },
1061
85
  }));
1062
86
  const defaultAppId = suggestAppId(name);
@@ -1071,7 +95,6 @@ const promptForOptions = async (options) => {
1071
95
  if (!isValidAppId(value)) {
1072
96
  return "App ID must be reverse domain notation (e.g., com.example.myapp)";
1073
97
  }
1074
- return undefined;
1075
98
  },
1076
99
  }));
1077
100
  const packageManager = options.packageManager ??
@@ -1105,33 +128,35 @@ const promptForOptions = async (options) => {
1105
128
  };
1106
129
  const scaffoldProject = (projectPath, resolved) => {
1107
130
  const { name, appId, testing, claudeSkills } = resolved;
131
+ const context = createTemplateContext(name, appId, testing);
1108
132
  mkdirSync(projectPath, { recursive: true });
1109
133
  mkdirSync(join(projectPath, "src"), { recursive: true });
1110
134
  if (testing !== "none") {
1111
135
  mkdirSync(join(projectPath, "tests"), { recursive: true });
1112
136
  }
1113
- writeFileSync(join(projectPath, "package.json"), generatePackageJson(name, appId, testing));
1114
- writeFileSync(join(projectPath, "tsconfig.json"), generateTsConfig());
1115
- writeFileSync(join(projectPath, "src", "app.tsx"), generateAppTsx(name, appId));
1116
- writeFileSync(join(projectPath, "src", "index.tsx"), generateIndexTsx());
1117
- writeFileSync(join(projectPath, ".gitignore"), generateGitignore());
137
+ writeFileSync(join(projectPath, "package.json"), renderFile("package.json.ejs", context));
138
+ writeFileSync(join(projectPath, "tsconfig.json"), renderFile("tsconfig.json.ejs", context));
139
+ writeFileSync(join(projectPath, "src", "app.tsx"), renderFile("src/app.tsx.ejs", context));
140
+ writeFileSync(join(projectPath, "src", "dev.tsx"), renderFile("src/dev.tsx.ejs", context));
141
+ writeFileSync(join(projectPath, "src", "index.tsx"), renderFile("src/index.tsx.ejs", context));
142
+ writeFileSync(join(projectPath, ".gitignore"), renderFile("gitignore.ejs", context));
1118
143
  if (claudeSkills) {
1119
144
  const skillsDir = join(projectPath, ".claude", "skills", "developing-gtkx-apps");
1120
145
  mkdirSync(skillsDir, { recursive: true });
1121
- writeFileSync(join(skillsDir, "SKILL.md"), generateSkillMd());
1122
- writeFileSync(join(skillsDir, "WIDGETS.md"), generateWidgetsMd());
1123
- writeFileSync(join(skillsDir, "EXAMPLES.md"), generateExamplesMd());
146
+ writeFileSync(join(skillsDir, "SKILL.md"), renderFile("claude/SKILL.md.ejs", context));
147
+ writeFileSync(join(skillsDir, "WIDGETS.md"), renderFile("claude/WIDGETS.md.ejs", context));
148
+ writeFileSync(join(skillsDir, "EXAMPLES.md"), renderFile("claude/EXAMPLES.md.ejs", context));
1124
149
  }
1125
150
  if (testing === "vitest") {
1126
- writeFileSync(join(projectPath, "vitest.config.ts"), generateVitestConfig());
1127
- writeFileSync(join(projectPath, "tests", "app.test.tsx"), generateExampleTest(testing));
151
+ writeFileSync(join(projectPath, "vitest.config.ts"), renderFile("config/vitest.config.ts.ejs", context));
152
+ writeFileSync(join(projectPath, "tests", "app.test.tsx"), renderFile("tests/app.test.tsx.ejs", context));
1128
153
  }
1129
154
  else if (testing === "jest") {
1130
- writeFileSync(join(projectPath, "jest.config.js"), generateJestConfig());
1131
- writeFileSync(join(projectPath, "tests", "app.test.tsx"), generateExampleTest(testing));
155
+ writeFileSync(join(projectPath, "jest.config.js"), renderFile("config/jest.config.js.ejs", context));
156
+ writeFileSync(join(projectPath, "tests", "app.test.tsx"), renderFile("tests/app.test.tsx.ejs", context));
1132
157
  }
1133
158
  else if (testing === "node") {
1134
- writeFileSync(join(projectPath, "tests", "app.test.tsx"), generateExampleTest(testing));
159
+ writeFileSync(join(projectPath, "tests", "app.test.tsx"), renderFile("tests/app.test.tsx.ejs", context));
1135
160
  }
1136
161
  };
1137
162
  const getDevDependencies = (testing) => {
@@ -1176,9 +201,32 @@ To run tests, you need xvfb installed:
1176
201
  p.note(`${nextSteps}${testingNote}`, "Next steps");
1177
202
  };
1178
203
  /**
1179
- * Creates a new GTKX application with interactive prompts.
1180
- * Scaffolds project structure, installs dependencies, and sets up configuration.
1181
- * @param options - Pre-filled options to skip interactive prompts
204
+ * Creates a new GTKX project with interactive prompts.
205
+ *
206
+ * Scaffolds a complete project structure including:
207
+ * - TypeScript configuration
208
+ * - React component template
209
+ * - Development server entry point
210
+ * - Optional testing setup
211
+ * - Optional Claude Code skills
212
+ *
213
+ * @param options - Pre-filled options to skip prompts
214
+ *
215
+ * @example
216
+ * ```tsx
217
+ * import { createApp } from "@gtkx/cli";
218
+ *
219
+ * // Interactive mode
220
+ * await createApp();
221
+ *
222
+ * // With pre-filled options
223
+ * await createApp({
224
+ * name: "my-app",
225
+ * appId: "com.example.myapp",
226
+ * packageManager: "pnpm",
227
+ * testing: "vitest",
228
+ * });
229
+ * ```
1182
230
  */
1183
231
  export const createApp = async (options = {}) => {
1184
232
  p.intro("Create GTKX App");