@gtkx/cli 0.9.3 → 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,836 +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:
192
- \`\`\`tsx
193
- <ListView.Root
194
- vexpand
195
- renderItem={(item: Item | null) => (
196
- <Label label={item?.text ?? ""} />
197
- )}
198
- >
199
- {items.map(item => (
200
- <ListView.Item key={item.id} item={item} />
201
- ))}
202
- </ListView.Root>
203
- \`\`\`
204
-
205
- **ColumnView** - Table with columns:
206
- \`\`\`tsx
207
- <ColumnView.Root
208
- sortColumn="name"
209
- sortOrder={Gtk.SortType.ASCENDING}
210
- onSortChange={handleSort}
211
- >
212
- <ColumnView.Column
213
- title="Name"
214
- id="name"
215
- expand
216
- renderCell={(item: Item | null) => (
217
- <Label label={item?.name ?? ""} />
218
- )}
219
- />
220
- {items.map(item => (
221
- <ColumnView.Item key={item.id} item={item} />
222
- ))}
223
- </ColumnView.Root>
224
- \`\`\`
225
-
226
- **DropDown** - Selection widget:
227
- \`\`\`tsx
228
- <DropDown.Root
229
- itemLabel={(opt: Option) => opt.label}
230
- onSelectionChanged={(item, index) => setSelected(item.value)}
231
- >
232
- {options.map(opt => (
233
- <DropDown.Item key={opt.value} item={opt} />
234
- ))}
235
- </DropDown.Root>
236
- \`\`\`
237
-
238
- ### Controlled Input
239
-
240
- Entry requires two-way binding:
241
- \`\`\`tsx
242
- const [text, setText] = useState("");
243
-
244
- <Entry
245
- text={text}
246
- onChanged={(entry) => setText(entry.getText())}
247
- placeholder="Type here..."
248
- />
249
- \`\`\`
250
-
251
- ### Declarative Menus
252
-
253
- \`\`\`tsx
254
- <ApplicationMenu>
255
- <Menu.Submenu label="File">
256
- <Menu.Item
257
- label="New"
258
- onActivate={handleNew}
259
- accels="<Control>n"
260
- />
261
- <Menu.Section>
262
- <Menu.Item label="Quit" onActivate={quit} accels="<Control>q" />
263
- </Menu.Section>
264
- </Menu.Submenu>
265
- </ApplicationMenu>
266
- \`\`\`
267
-
268
- ## Signal Handling
269
-
270
- GTK signals map to \`on<SignalName>\` props:
271
- - \`clicked\` → \`onClicked\`
272
- - \`toggled\` → \`onToggled\`
273
- - \`changed\` → \`onChanged\`
274
- - \`notify::selected\` → \`onNotifySelected\`
275
-
276
- ## Widget References
277
-
278
- \`\`\`tsx
279
- import { useRef } from "react";
280
-
281
- const entryRef = useRef<Gtk.Entry | null>(null);
282
- <Entry ref={entryRef} />
283
- // Later: entryRef.current?.getText()
284
- \`\`\`
285
-
286
- ## Portals
287
-
288
- \`\`\`tsx
289
- import { createPortal } from "@gtkx/react";
290
-
291
- {createPortal(<AboutDialog programName="My App" />)}
292
- \`\`\`
293
-
294
- ## Constraints
295
-
296
- - **GTK is single-threaded**: All widget operations on main thread
297
- - **Virtual lists need immutable data**: Use stable object references
298
- - **ToggleButton auto-prevents feedback loops**: Safe for controlled state
299
- - **Entry needs two-way binding**: Use \`onChanged\` to sync state
300
-
301
- For detailed widget reference, see [WIDGETS.md](WIDGETS.md).
302
- For code examples, see [EXAMPLES.md](EXAMPLES.md).
303
- `;
304
- };
305
- const generateWidgetsMd = () => {
306
- return `# GTKX Widget Reference
307
-
308
- ## Container Widgets
309
-
310
- ### Box
311
- Linear layout container.
312
-
313
- \`\`\`tsx
314
- <Box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
315
- <Label label="Child 1" />
316
- <Label label="Child 2" />
317
- </Box>
318
- \`\`\`
319
-
320
- Props:
321
- - \`orientation\`: \`Gtk.Orientation.HORIZONTAL\` | \`Gtk.Orientation.VERTICAL\`
322
- - \`spacing\`: number (pixels between children)
323
- - \`homogeneous\`: boolean (equal child sizes)
324
-
325
- ### Grid
326
- 2D grid layout with explicit positioning.
327
-
328
- \`\`\`tsx
329
- <Grid.Root spacing={10} rowSpacing={5} columnSpacing={5}>
330
- <Grid.Child column={0} row={0}>
331
- <Label label="Top-left" />
332
- </Grid.Child>
333
- <Grid.Child column={1} row={0} columnSpan={2}>
334
- <Label label="Spans 2 columns" />
335
- </Grid.Child>
336
- </Grid.Root>
337
- \`\`\`
338
-
339
- Grid.Child props (consumed, not passed to GTK):
340
- - \`column\`: number (0-indexed)
341
- - \`row\`: number (0-indexed)
342
- - \`columnSpan\`: number (default 1)
343
- - \`rowSpan\`: number (default 1)
344
-
345
- ### Stack
346
- Shows one child at a time, switchable by name.
347
-
348
- \`\`\`tsx
349
- <Stack.Root visibleChildName="page1">
350
- <Stack.Page name="page1" title="First" iconName="document-new">
351
- <Content1 />
352
- </Stack.Page>
353
- <Stack.Page name="page2" title="Second">
354
- <Content2 />
355
- </Stack.Page>
356
- </Stack.Root>
357
- \`\`\`
358
-
359
- Stack.Page props (consumed):
360
- - \`name\`: string (required, unique identifier)
361
- - \`title\`: string (display title)
362
- - \`iconName\`: string (icon name)
363
-
364
- ### Notebook
365
- Tabbed container with visible tabs.
366
-
367
- \`\`\`tsx
368
- <Notebook.Root>
369
- <Notebook.Page label="Tab 1">
370
- <Content1 />
371
- </Notebook.Page>
372
- <Notebook.Page label="Tab 2">
373
- <Content2 />
374
- </Notebook.Page>
375
- </Notebook.Root>
376
- \`\`\`
377
-
378
- Notebook.Page props (consumed):
379
- - \`label\`: string (tab label)
380
-
381
- ### Paned
382
- Resizable split container with draggable divider.
383
-
384
- \`\`\`tsx
385
- <Paned.Root
386
- orientation={Gtk.Orientation.HORIZONTAL}
387
- position={280}
388
- shrinkStartChild={false}
389
- shrinkEndChild={false}
390
- >
391
- <Paned.StartChild>
392
- <SidePanel />
393
- </Paned.StartChild>
394
- <Paned.EndChild>
395
- <MainContent />
396
- </Paned.EndChild>
397
- </Paned.Root>
398
- \`\`\`
399
-
400
- Props:
401
- - \`orientation\`: \`Gtk.Orientation.HORIZONTAL\` | \`Gtk.Orientation.VERTICAL\`
402
- - \`position\`: number (divider position in pixels)
403
- - \`shrinkStartChild\`: boolean
404
- - \`shrinkEndChild\`: boolean
405
-
406
- ## Virtual Scrolling Widgets
407
-
408
- ### ListView
409
- High-performance scrollable list with virtual rendering.
410
-
411
- \`\`\`tsx
412
- <ListView.Root
413
- vexpand
414
- renderItem={(item: Item | null) => (
415
- <Label label={item?.text ?? ""} />
416
- )}
417
- >
418
- {items.map(item => (
419
- <ListView.Item key={item.id} item={item} />
420
- ))}
421
- </ListView.Root>
422
- \`\`\`
423
-
424
- Props:
425
- - \`renderItem\`: \`(item: T | null) => ReactElement\` (required)
426
- - Standard scrollable props
427
-
428
- ### ColumnView
429
- Table with sortable columns.
430
-
431
- \`\`\`tsx
432
- <ColumnView.Root
433
- sortColumn="name"
434
- sortOrder={Gtk.SortType.ASCENDING}
435
- onSortChange={(column, order) => handleSort(column, order)}
436
- >
437
- <ColumnView.Column
438
- title="Name"
439
- id="name"
440
- expand
441
- resizable
442
- renderCell={(item: Item | null) => (
443
- <Label label={item?.name ?? ""} />
444
- )}
445
- />
446
- {items.map(item => (
447
- <ColumnView.Item key={item.id} item={item} />
448
- ))}
449
- </ColumnView.Root>
450
- \`\`\`
451
-
452
- ### DropDown
453
- Selection dropdown with custom rendering.
454
-
455
- \`\`\`tsx
456
- <DropDown.Root
457
- itemLabel={(opt: Option) => opt.label}
458
- onSelectionChanged={(item, index) => setSelected(item.value)}
459
- >
460
- {options.map(opt => (
461
- <DropDown.Item key={opt.value} item={opt} />
462
- ))}
463
- </DropDown.Root>
464
- \`\`\`
465
-
466
- ## Input Widgets
467
-
468
- ### Entry
469
- Single-line text input. Requires two-way binding for controlled behavior.
470
-
471
- \`\`\`tsx
472
- const [text, setText] = useState("");
473
-
474
- <Entry
475
- text={text}
476
- onChanged={(entry) => setText(entry.getText())}
477
- placeholder="Enter text..."
478
- />
479
- \`\`\`
480
-
481
- ### ToggleButton
482
- Toggle button with controlled state. Auto-prevents signal feedback loops.
483
-
484
- \`\`\`tsx
485
- const [active, setActive] = useState(false);
486
-
487
- <ToggleButton.Root
488
- active={active}
489
- onToggled={() => setActive(!active)}
490
- label="Toggle me"
491
- />
492
- \`\`\`
493
-
494
- ## Display Widgets
495
-
496
- ### Label
497
- \`\`\`tsx
498
- <Label label="Hello World" halign={Gtk.Align.START} wrap useMarkup />
499
- \`\`\`
500
-
501
- ### Button
502
- \`\`\`tsx
503
- <Button label="Click me" onClicked={() => handleClick()} iconName="document-new" />
504
- \`\`\`
505
-
506
- ### MenuButton
507
- \`\`\`tsx
508
- <MenuButton.Root label="Options" iconName="open-menu">
509
- <MenuButton.Popover>
510
- <PopoverMenu.Root>
511
- <Menu.Item label="Action" onActivate={handle} />
512
- </PopoverMenu.Root>
513
- </MenuButton.Popover>
514
- </MenuButton.Root>
515
- \`\`\`
516
-
517
- ## Menu Widgets
518
-
519
- ### ApplicationMenu
520
- \`\`\`tsx
521
- <ApplicationMenu>
522
- <Menu.Submenu label="File">
523
- <Menu.Item label="New" onActivate={handleNew} accels="<Control>n" />
524
- <Menu.Section>
525
- <Menu.Item label="Quit" onActivate={quit} accels="<Control>q" />
526
- </Menu.Section>
527
- </Menu.Submenu>
528
- </ApplicationMenu>
529
- \`\`\`
530
-
531
- ### Menu.Item
532
- Props:
533
- - \`label\`: string
534
- - \`onActivate\`: \`() => void\`
535
- - \`accels\`: string | string[] (e.g., "<Control>n")
536
-
537
- ### Menu.Section
538
- Groups menu items with optional label.
539
-
540
- ### Menu.Submenu
541
- Nested submenu.
542
-
543
- ## Window Widgets
544
-
545
- ### ApplicationWindow
546
- \`\`\`tsx
547
- <ApplicationWindow
548
- title="My App"
549
- defaultWidth={800}
550
- defaultHeight={600}
551
- showMenubar
552
- onCloseRequest={() => false}
553
- >
554
- <MainContent />
555
- </ApplicationWindow>
556
- \`\`\`
557
-
558
- ## Common Props
559
-
560
- All widgets support:
561
- - \`hexpand\` / \`vexpand\`: boolean (expand to fill space)
562
- - \`halign\` / \`valign\`: \`Gtk.Align.START\` | \`CENTER\` | \`END\` | \`FILL\`
563
- - \`marginStart\` / \`marginEnd\` / \`marginTop\` / \`marginBottom\`: number
564
- - \`sensitive\`: boolean (enabled/disabled)
565
- - \`visible\`: boolean
566
- - \`cssClasses\`: string[]
567
- `;
568
- };
569
- const generateExamplesMd = () => {
570
- return `# GTKX Code Examples
571
-
572
- ## Application Structure
573
-
574
- ### Basic App with State
575
-
576
- \`\`\`tsx
577
- import * as Gtk from "@gtkx/ffi/gtk";
578
- import { ApplicationWindow, Box, Label, quit } from "@gtkx/react";
579
- import { useCallback, useState } from "react";
580
-
581
- interface Todo {
582
- id: number;
583
- text: string;
584
- completed: boolean;
585
- }
586
-
587
- let nextId = 1;
588
-
589
- export const App = () => {
590
- const [todos, setTodos] = useState<Todo[]>([]);
591
-
592
- const addTodo = useCallback((text: string) => {
593
- setTodos((prev) => [...prev, { id: nextId++, text, completed: false }]);
594
- }, []);
595
-
596
- const toggleTodo = useCallback((id: number) => {
597
- setTodos((prev) =>
598
- prev.map((todo) =>
599
- todo.id === id ? { ...todo, completed: !todo.completed } : todo
600
- )
601
- );
602
- }, []);
603
-
604
- return (
605
- <ApplicationWindow title="Todo App" defaultWidth={400} defaultHeight={500} onCloseRequest={quit}>
606
- <Box orientation={Gtk.Orientation.VERTICAL} spacing={16} marginTop={16} marginStart={16} marginEnd={16}>
607
- <Label label="Todo App" />
608
- </Box>
609
- </ApplicationWindow>
610
- );
611
- };
612
-
613
- export const appId = "com.gtkx.todo";
614
- \`\`\`
615
-
616
- ## Layout Patterns
617
-
618
- ### Grid for Forms
619
-
620
- \`\`\`tsx
621
- import * as Gtk from "@gtkx/ffi/gtk";
622
- import { Button, Entry, Grid, Label } from "@gtkx/react";
623
- import { useState } from "react";
624
-
625
- const FormLayout = () => {
626
- const [name, setName] = useState("");
627
- const [email, setEmail] = useState("");
628
-
629
- return (
630
- <Grid.Root rowSpacing={8} columnSpacing={12}>
631
- <Grid.Child column={0} row={0}>
632
- <Label label="Name:" halign={Gtk.Align.END} />
633
- </Grid.Child>
634
- <Grid.Child column={1} row={0}>
635
- <Entry text={name} onChanged={(e) => setName(e.getText())} hexpand />
636
- </Grid.Child>
637
- <Grid.Child column={0} row={1}>
638
- <Label label="Email:" halign={Gtk.Align.END} />
639
- </Grid.Child>
640
- <Grid.Child column={1} row={1}>
641
- <Entry text={email} onChanged={(e) => setEmail(e.getText())} hexpand />
642
- </Grid.Child>
643
- <Grid.Child column={0} row={2} columnSpan={2}>
644
- <Button label="Submit" halign={Gtk.Align.END} marginTop={8} />
645
- </Grid.Child>
646
- </Grid.Root>
647
- );
648
- };
649
- \`\`\`
650
-
651
- ### Stack with StackSwitcher
652
-
653
- \`\`\`tsx
654
- import * as Gtk from "@gtkx/ffi/gtk";
655
- import { Box, Label, Stack, StackSwitcher } from "@gtkx/react";
656
-
657
- const TabContainer = () => (
658
- <Box orientation={Gtk.Orientation.VERTICAL} spacing={8}>
659
- <StackSwitcher.Root
660
- ref={(switcher: Gtk.StackSwitcher | null) => {
661
- if (switcher) {
662
- const stack = switcher.getParent()?.getLastChild() as Gtk.Stack | null;
663
- if (stack) switcher.setStack(stack);
664
- }
665
- }}
666
- />
667
- <Stack.Root transitionType={Gtk.StackTransitionType.SLIDE_LEFT_RIGHT} transitionDuration={200}>
668
- <Stack.Page name="page1" title="First">
669
- <Label label="First Page Content" />
670
- </Stack.Page>
671
- <Stack.Page name="page2" title="Second">
672
- <Label label="Second Page Content" />
673
- </Stack.Page>
674
- </Stack.Root>
675
- </Box>
676
- );
677
- \`\`\`
678
-
679
- ## Virtual Scrolling Lists
680
-
681
- ### ListView with Custom Rendering
682
-
683
- \`\`\`tsx
684
- import * as Gtk from "@gtkx/ffi/gtk";
685
- import { Box, Label, ListView } from "@gtkx/react";
686
-
687
- interface Task {
688
- id: string;
689
- title: string;
690
- completed: boolean;
691
- }
692
-
693
- const tasks: Task[] = [
694
- { id: "1", title: "Learn GTK4", completed: true },
695
- { id: "2", title: "Build React app", completed: false },
696
- ];
697
-
698
- const TaskList = () => (
699
- <Box cssClasses={["card"]} heightRequest={250}>
700
- <ListView.Root
701
- vexpand
702
- renderItem={(task: Task | null) => (
703
- <Label
704
- label={task?.title ?? ""}
705
- cssClasses={task?.completed ? ["dim-label"] : []}
706
- halign={Gtk.Align.START}
707
- marginStart={12}
708
- marginTop={8}
709
- marginBottom={8}
710
- />
711
- )}
712
- >
713
- {tasks.map((task) => (
714
- <ListView.Item key={task.id} item={task} />
715
- ))}
716
- </ListView.Root>
717
- </Box>
718
- );
719
- \`\`\`
720
-
721
- ## Menus
722
-
723
- ### MenuButton with PopoverMenu
724
-
725
- \`\`\`tsx
726
- import * as Gtk from "@gtkx/ffi/gtk";
727
- import { Box, Label, Menu, MenuButton, PopoverMenu } from "@gtkx/react";
728
- import { useState } from "react";
729
-
730
- const MenuDemo = () => {
731
- const [lastAction, setLastAction] = useState<string | null>(null);
732
-
733
- return (
734
- <Box orientation={Gtk.Orientation.VERTICAL} spacing={12}>
735
- <Label label={\`Last action: \${lastAction ?? "(none)"}\`} />
736
- <MenuButton.Root label="Actions">
737
- <MenuButton.Popover>
738
- <PopoverMenu.Root>
739
- <Menu.Item label="New" onActivate={() => setLastAction("New")} accels="<Control>n" />
740
- <Menu.Item label="Open" onActivate={() => setLastAction("Open")} accels="<Control>o" />
741
- <Menu.Item label="Save" onActivate={() => setLastAction("Save")} accels="<Control>s" />
742
- </PopoverMenu.Root>
743
- </MenuButton.Popover>
744
- </MenuButton.Root>
745
- </Box>
746
- );
747
- };
748
- \`\`\`
749
-
750
- ## Component Props Pattern
751
-
752
- ### List Item Component
753
-
754
- \`\`\`tsx
755
- import * as Gtk from "@gtkx/ffi/gtk";
756
- import { Box, Button, CheckButton, Label } from "@gtkx/react";
757
-
758
- interface Todo {
759
- id: number;
760
- text: string;
761
- completed: boolean;
762
- }
763
-
764
- interface TodoItemProps {
765
- todo: Todo;
766
- onToggle: (id: number) => void;
767
- onDelete: (id: number) => void;
768
- }
769
-
770
- export const TodoItem = ({ todo, onToggle, onDelete }: TodoItemProps) => (
771
- <Box orientation={Gtk.Orientation.HORIZONTAL} spacing={8}>
772
- <CheckButton active={todo.completed} onToggled={() => onToggle(todo.id)} />
773
- <Label label={todo.text} hexpand cssClasses={todo.completed ? ["dim-label"] : []} />
774
- <Button iconName="edit-delete-symbolic" onClicked={() => onDelete(todo.id)} cssClasses={["flat"]} />
775
- </Box>
776
- );
777
- \`\`\`
778
- `;
779
- };
780
- const generateExampleTest = (testing) => {
781
- const imports = testing === "vitest"
782
- ? `import { describe, it, expect, afterEach } from "vitest";`
783
- : testing === "jest"
784
- ? `import { describe, it, expect, afterEach } from "@jest/globals";`
785
- : `import { describe, it, after } from "node:test";
786
- import { strict as assert } from "node:assert";`;
787
- const afterEachFn = testing === "node" ? "after" : "afterEach";
788
- const assertion = testing === "node" ? `assert.ok(button, "Button should be rendered");` : `expect(button).toBeDefined();`;
789
- return `${imports}
790
- import * as Gtk from "@gtkx/ffi/gtk";
791
- import { cleanup, render, screen } from "@gtkx/testing";
792
- import App from "../src/app.js";
793
-
794
- ${afterEachFn}(async () => {
795
- await cleanup();
796
- });
797
-
798
- describe("App", () => {
799
- it("renders the increment button", async () => {
800
- await render(<App />, { wrapper: false });
801
- const button = await screen.findByRole(Gtk.AccessibleRole.BUTTON, { name: "Increment" });
802
- ${assertion}
803
- });
804
- });
805
- `;
806
- };
807
- const generateVitestConfig = () => {
808
- return `import { defineConfig } from "vitest/config";
809
-
810
- export default defineConfig({
811
- test: {
812
- include: ["tests/**/*.test.{ts,tsx}"],
813
- globals: false,
814
- },
815
- esbuild: {
816
- jsx: "automatic",
817
- },
818
- });
819
- `;
820
- };
821
- const generateJestConfig = () => {
822
- return `/** @type {import('jest').Config} */
823
- export default {
824
- preset: "ts-jest/presets/default-esm",
825
- testEnvironment: "node",
826
- testMatch: ["**/tests/**/*.test.ts"],
827
- extensionsToTreatAsEsm: [".ts", ".tsx"],
828
- moduleNameMapper: {
829
- "^(\\\\.{1,2}/.*)\\\\.js$": "$1",
830
- },
831
- transform: {
832
- "^.+\\\\.tsx?$": [
833
- "ts-jest",
834
- {
835
- useESM: true,
836
- tsconfig: "tsconfig.json",
837
- },
838
- ],
839
- },
840
- };
841
- `;
18
+ return { name, appId, title, testing };
842
19
  };
843
20
  export const getAddCommand = (pm, deps, dev) => {
844
21
  const devFlag = dev ? (pm === "npm" ? "--save-dev" : "-D") : "";
@@ -904,7 +81,6 @@ const promptForOptions = async (options) => {
904
81
  if (existsSync(resolve(process.cwd(), value))) {
905
82
  return `Directory "${value}" already exists`;
906
83
  }
907
- return undefined;
908
84
  },
909
85
  }));
910
86
  const defaultAppId = suggestAppId(name);
@@ -919,7 +95,6 @@ const promptForOptions = async (options) => {
919
95
  if (!isValidAppId(value)) {
920
96
  return "App ID must be reverse domain notation (e.g., com.example.myapp)";
921
97
  }
922
- return undefined;
923
98
  },
924
99
  }));
925
100
  const packageManager = options.packageManager ??
@@ -953,33 +128,35 @@ const promptForOptions = async (options) => {
953
128
  };
954
129
  const scaffoldProject = (projectPath, resolved) => {
955
130
  const { name, appId, testing, claudeSkills } = resolved;
131
+ const context = createTemplateContext(name, appId, testing);
956
132
  mkdirSync(projectPath, { recursive: true });
957
133
  mkdirSync(join(projectPath, "src"), { recursive: true });
958
134
  if (testing !== "none") {
959
135
  mkdirSync(join(projectPath, "tests"), { recursive: true });
960
136
  }
961
- writeFileSync(join(projectPath, "package.json"), generatePackageJson(name, appId, testing));
962
- writeFileSync(join(projectPath, "tsconfig.json"), generateTsConfig());
963
- writeFileSync(join(projectPath, "src", "app.tsx"), generateAppTsx(name, appId));
964
- writeFileSync(join(projectPath, "src", "index.tsx"), generateIndexTsx());
965
- 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));
966
143
  if (claudeSkills) {
967
144
  const skillsDir = join(projectPath, ".claude", "skills", "developing-gtkx-apps");
968
145
  mkdirSync(skillsDir, { recursive: true });
969
- writeFileSync(join(skillsDir, "SKILL.md"), generateSkillMd());
970
- writeFileSync(join(skillsDir, "WIDGETS.md"), generateWidgetsMd());
971
- 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));
972
149
  }
973
150
  if (testing === "vitest") {
974
- writeFileSync(join(projectPath, "vitest.config.ts"), generateVitestConfig());
975
- 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));
976
153
  }
977
154
  else if (testing === "jest") {
978
- writeFileSync(join(projectPath, "jest.config.js"), generateJestConfig());
979
- 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));
980
157
  }
981
158
  else if (testing === "node") {
982
- writeFileSync(join(projectPath, "tests", "app.test.tsx"), generateExampleTest(testing));
159
+ writeFileSync(join(projectPath, "tests", "app.test.tsx"), renderFile("tests/app.test.tsx.ejs", context));
983
160
  }
984
161
  };
985
162
  const getDevDependencies = (testing) => {
@@ -1024,9 +201,32 @@ To run tests, you need xvfb installed:
1024
201
  p.note(`${nextSteps}${testingNote}`, "Next steps");
1025
202
  };
1026
203
  /**
1027
- * Creates a new GTKX application with interactive prompts.
1028
- * Scaffolds project structure, installs dependencies, and sets up configuration.
1029
- * @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
+ * ```
1030
230
  */
1031
231
  export const createApp = async (options = {}) => {
1032
232
  p.intro("Create GTKX App");