crosspad-mcp-server 4.0.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.
Files changed (80) hide show
  1. package/README.md +187 -0
  2. package/dist/config.d.ts +10 -0
  3. package/dist/config.js +33 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +360 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/tools/architecture.d.ts +16 -0
  9. package/dist/tools/architecture.js +198 -0
  10. package/dist/tools/architecture.js.map +1 -0
  11. package/dist/tools/build-check.d.ts +23 -0
  12. package/dist/tools/build-check.js +162 -0
  13. package/dist/tools/build-check.js.map +1 -0
  14. package/dist/tools/build.d.ts +14 -0
  15. package/dist/tools/build.js +101 -0
  16. package/dist/tools/build.js.map +1 -0
  17. package/dist/tools/diff-core.d.ts +24 -0
  18. package/dist/tools/diff-core.js +88 -0
  19. package/dist/tools/diff-core.js.map +1 -0
  20. package/dist/tools/idf-build.d.ts +10 -0
  21. package/dist/tools/idf-build.js +155 -0
  22. package/dist/tools/idf-build.js.map +1 -0
  23. package/dist/tools/input.d.ts +36 -0
  24. package/dist/tools/input.js +61 -0
  25. package/dist/tools/input.js.map +1 -0
  26. package/dist/tools/log.d.ts +16 -0
  27. package/dist/tools/log.js +49 -0
  28. package/dist/tools/log.js.map +1 -0
  29. package/dist/tools/repos.d.ts +12 -0
  30. package/dist/tools/repos.js +63 -0
  31. package/dist/tools/repos.js.map +1 -0
  32. package/dist/tools/scaffold.d.ts +15 -0
  33. package/dist/tools/scaffold.js +192 -0
  34. package/dist/tools/scaffold.js.map +1 -0
  35. package/dist/tools/screenshot.d.ts +24 -0
  36. package/dist/tools/screenshot.js +80 -0
  37. package/dist/tools/screenshot.js.map +1 -0
  38. package/dist/tools/settings.d.ts +25 -0
  39. package/dist/tools/settings.js +48 -0
  40. package/dist/tools/settings.js.map +1 -0
  41. package/dist/tools/stats.d.ts +18 -0
  42. package/dist/tools/stats.js +31 -0
  43. package/dist/tools/stats.js.map +1 -0
  44. package/dist/tools/symbols.d.ts +20 -0
  45. package/dist/tools/symbols.js +157 -0
  46. package/dist/tools/symbols.js.map +1 -0
  47. package/dist/tools/test.d.ts +24 -0
  48. package/dist/tools/test.js +227 -0
  49. package/dist/tools/test.js.map +1 -0
  50. package/dist/utils/exec.d.ts +58 -0
  51. package/dist/utils/exec.js +292 -0
  52. package/dist/utils/exec.js.map +1 -0
  53. package/dist/utils/git.d.ts +10 -0
  54. package/dist/utils/git.js +29 -0
  55. package/dist/utils/git.js.map +1 -0
  56. package/dist/utils/remote-client.d.ts +17 -0
  57. package/dist/utils/remote-client.js +94 -0
  58. package/dist/utils/remote-client.js.map +1 -0
  59. package/package.json +21 -0
  60. package/server.json +23 -0
  61. package/src/config.ts +45 -0
  62. package/src/index.ts +484 -0
  63. package/src/tools/architecture.ts +260 -0
  64. package/src/tools/build-check.ts +178 -0
  65. package/src/tools/build.ts +130 -0
  66. package/src/tools/diff-core.ts +130 -0
  67. package/src/tools/idf-build.ts +182 -0
  68. package/src/tools/input.ts +80 -0
  69. package/src/tools/log.ts +75 -0
  70. package/src/tools/repos.ts +75 -0
  71. package/src/tools/scaffold.ts +229 -0
  72. package/src/tools/screenshot.ts +100 -0
  73. package/src/tools/settings.ts +68 -0
  74. package/src/tools/stats.ts +38 -0
  75. package/src/tools/symbols.ts +185 -0
  76. package/src/tools/test.ts +264 -0
  77. package/src/utils/exec.ts +376 -0
  78. package/src/utils/git.ts +45 -0
  79. package/src/utils/remote-client.ts +107 -0
  80. package/tsconfig.json +16 -0
@@ -0,0 +1,182 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { CROSSPAD_IDF_ROOT } from "../config.js";
4
+ import { runWithIdf, runWithIdfStream, OnLine } from "../utils/exec.js";
5
+
6
+ export interface IdfBuildResult {
7
+ success: boolean;
8
+ duration_seconds: number;
9
+ errors: string[];
10
+ warnings: string[];
11
+ tail: string[];
12
+ auto_reconfigured?: boolean;
13
+ }
14
+
15
+ /**
16
+ * Detect app directories that have REGISTER_APP in their sources but are NOT
17
+ * listed in the auto-generated app_registry_init.cpp. This means CMake hasn't
18
+ * seen them yet (file(GLOB) only runs at configure time).
19
+ */
20
+ function detectUnregisteredApps(): string[] {
21
+ const appsDir = path.join(CROSSPAD_IDF_ROOT, "main", "app");
22
+ const registryFile = path.join(appsDir, "app_registry_init.cpp");
23
+
24
+ if (!fs.existsSync(registryFile)) return [];
25
+
26
+ const registryContent = fs.readFileSync(registryFile, "utf-8");
27
+
28
+ const unregistered: string[] = [];
29
+
30
+ let entries: fs.Dirent[];
31
+ try {
32
+ entries = fs.readdirSync(appsDir, { withFileTypes: true });
33
+ } catch {
34
+ return [];
35
+ }
36
+
37
+ for (const entry of entries) {
38
+ if (!entry.isDirectory()) continue;
39
+ const appCmake = path.join(appsDir, entry.name, "CMakeLists.txt");
40
+ if (!fs.existsSync(appCmake)) continue;
41
+
42
+ // Scan .cpp files in this app dir for REGISTER_APP
43
+ const appDir = path.join(appsDir, entry.name);
44
+ let hasRegisterApp = false;
45
+ let appName = "";
46
+
47
+ try {
48
+ for (const file of fs.readdirSync(appDir)) {
49
+ if (!file.endsWith(".cpp")) continue;
50
+ const content = fs.readFileSync(path.join(appDir, file), "utf-8");
51
+ const match = content.match(/REGISTER_APP\((\w+)/);
52
+ if (match) {
53
+ hasRegisterApp = true;
54
+ appName = match[1];
55
+ break;
56
+ }
57
+ }
58
+ } catch {
59
+ continue;
60
+ }
61
+
62
+ if (hasRegisterApp && appName) {
63
+ // Check if this app is in the registry
64
+ const registerFn = `_register_${appName}_app`;
65
+ if (!registryContent.includes(registerFn)) {
66
+ unregistered.push(appName);
67
+ }
68
+ }
69
+ }
70
+
71
+ return unregistered;
72
+ }
73
+
74
+ function parseErrors(output: string): string[] {
75
+ const errors: string[] = [];
76
+ for (const line of output.split("\n")) {
77
+ const trimmed = line.trim();
78
+ if (!trimmed) continue;
79
+ if (/:\d+:\d+: (?:fatal )?error:/.test(trimmed)) {
80
+ errors.push(trimmed);
81
+ } else if (/^.*ld.*: error:/.test(trimmed) || /undefined reference to/.test(trimmed)) {
82
+ errors.push(trimmed);
83
+ } else if (/^CMake Error/i.test(trimmed)) {
84
+ errors.push(trimmed);
85
+ } else if (/FAILED:/.test(trimmed) && !trimmed.startsWith("[")) {
86
+ errors.push(trimmed);
87
+ }
88
+ }
89
+ return errors.slice(0, 30);
90
+ }
91
+
92
+ function parseWarnings(output: string): string[] {
93
+ const warnings: string[] = [];
94
+ for (const line of output.split("\n")) {
95
+ const trimmed = line.trim();
96
+ if (!trimmed) continue;
97
+ if (/:\d+:\d+: warning:/.test(trimmed)) {
98
+ warnings.push(trimmed);
99
+ }
100
+ }
101
+ return warnings.slice(0, 20);
102
+ }
103
+
104
+ function getTail(output: string, n: number): string[] {
105
+ return output.split("\n").filter(l => l.trim()).slice(-n);
106
+ }
107
+
108
+ async function runIdfCmd(
109
+ cmd: string,
110
+ onLine: OnLine | undefined,
111
+ timeoutMs: number,
112
+ ): Promise<{ stdout: string; stderr: string; success: boolean }> {
113
+ if (onLine) {
114
+ const r = await runWithIdfStream(cmd, CROSSPAD_IDF_ROOT, onLine, timeoutMs);
115
+ return r;
116
+ }
117
+ return runWithIdf(cmd, CROSSPAD_IDF_ROOT, timeoutMs);
118
+ }
119
+
120
+ export async function crosspadIdfBuild(
121
+ mode: "build" | "fullclean" | "clean",
122
+ onLine?: OnLine
123
+ ): Promise<IdfBuildResult> {
124
+ const startTime = Date.now();
125
+ let autoReconfigured = false;
126
+
127
+ // Auto-detect unregistered apps — if found, escalate to fullclean
128
+ if (mode === "build") {
129
+ const unregistered = detectUnregisteredApps();
130
+ if (unregistered.length > 0) {
131
+ onLine?.("stdout", `[idf] Detected ${unregistered.length} unregistered app(s): ${unregistered.join(", ")} — running fullclean`);
132
+ mode = "fullclean";
133
+ autoReconfigured = true;
134
+ }
135
+ }
136
+
137
+ if (mode === "fullclean") {
138
+ onLine?.("stdout", "[idf] Running idf.py fullclean...");
139
+ const r = await runIdfCmd("idf.py fullclean", onLine, 60_000);
140
+ if (!r.success) {
141
+ const combined = r.stdout + "\n" + r.stderr;
142
+ return {
143
+ success: false,
144
+ duration_seconds: (Date.now() - startTime) / 1000,
145
+ errors: parseErrors(combined),
146
+ warnings: [],
147
+ tail: getTail(combined, 20),
148
+ auto_reconfigured: autoReconfigured,
149
+ };
150
+ }
151
+ }
152
+
153
+ if (mode === "clean") {
154
+ const buildDir = path.join(CROSSPAD_IDF_ROOT, "build");
155
+ if (fs.existsSync(buildDir)) {
156
+ onLine?.("stdout", "[idf] Removing build directory...");
157
+ fs.rmSync(buildDir, { recursive: true, force: true });
158
+ }
159
+ }
160
+
161
+ onLine?.("stdout", "[idf] Building...");
162
+
163
+ const r = await runIdfCmd("idf.py build", onLine, 600_000);
164
+ const combined = r.stdout + "\n" + r.stderr;
165
+ const errors = parseErrors(combined);
166
+ const warnings = parseWarnings(combined);
167
+
168
+ const result: IdfBuildResult = {
169
+ success: r.success,
170
+ duration_seconds: (Date.now() - startTime) / 1000,
171
+ errors,
172
+ warnings,
173
+ tail: getTail(combined, r.success ? 10 : 30),
174
+ };
175
+
176
+ if (autoReconfigured) {
177
+ result.auto_reconfigured = true;
178
+ }
179
+
180
+ onLine?.("stdout", `[idf] Build ${result.success ? "succeeded" : "FAILED"} in ${result.duration_seconds.toFixed(1)}s`);
181
+ return result;
182
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * MCP tool: send input events to the running CrossPad simulator.
3
+ * Supports: click, pad_press/release, encoder_rotate/press/release, key.
4
+ */
5
+
6
+ import { sendRemoteCommand, isSimulatorRunning, RemoteResponse } from "../utils/remote-client.js";
7
+
8
+ export type InputAction =
9
+ | { action: "click"; x: number; y: number }
10
+ | { action: "pad_press"; pad: number; velocity?: number }
11
+ | { action: "pad_release"; pad: number }
12
+ | { action: "encoder_rotate"; delta: number }
13
+ | { action: "encoder_press" }
14
+ | { action: "encoder_release" }
15
+ | { action: "key"; keycode: number };
16
+
17
+ export interface InputResult {
18
+ success: boolean;
19
+ action: string;
20
+ response?: Record<string, unknown>;
21
+ error?: string;
22
+ }
23
+
24
+ /**
25
+ * Send a single input event to the simulator.
26
+ */
27
+ export async function crosspadInput(input: InputAction): Promise<InputResult> {
28
+ const running = await isSimulatorRunning();
29
+ if (!running) {
30
+ return {
31
+ success: false,
32
+ action: input.action,
33
+ error: "Simulator is not running. Use crosspad_run to start it.",
34
+ };
35
+ }
36
+
37
+ try {
38
+ let cmd: Record<string, unknown>;
39
+
40
+ switch (input.action) {
41
+ case "click":
42
+ cmd = { cmd: "click", x: input.x, y: input.y };
43
+ break;
44
+ case "pad_press":
45
+ cmd = { cmd: "pad_press", pad: input.pad, velocity: input.velocity ?? 127 };
46
+ break;
47
+ case "pad_release":
48
+ cmd = { cmd: "pad_release", pad: input.pad };
49
+ break;
50
+ case "encoder_rotate":
51
+ cmd = { cmd: "encoder_rotate", delta: input.delta };
52
+ break;
53
+ case "encoder_press":
54
+ cmd = { cmd: "encoder_press" };
55
+ break;
56
+ case "encoder_release":
57
+ cmd = { cmd: "encoder_release" };
58
+ break;
59
+ case "key":
60
+ cmd = { cmd: "key", keycode: input.keycode };
61
+ break;
62
+ default:
63
+ return { success: false, action: "unknown", error: "Unknown action" };
64
+ }
65
+
66
+ const resp = await sendRemoteCommand(cmd);
67
+ return {
68
+ success: resp.ok === true,
69
+ action: input.action,
70
+ response: resp as Record<string, unknown>,
71
+ error: resp.ok ? undefined : (resp.error as string),
72
+ };
73
+ } catch (err: any) {
74
+ return {
75
+ success: false,
76
+ action: input.action,
77
+ error: err.message,
78
+ };
79
+ }
80
+ }
@@ -0,0 +1,75 @@
1
+ import fs from "fs";
2
+ import { BIN_EXE, CROSSPAD_PC_ROOT } from "../config.js";
3
+ import { runBuildStream, OnLine } from "../utils/exec.js";
4
+
5
+ export interface LogResult {
6
+ success: boolean;
7
+ exe_path: string;
8
+ stdout: string;
9
+ stderr: string;
10
+ exit_code: number | null;
11
+ duration_seconds: number;
12
+ truncated: boolean;
13
+ }
14
+
15
+ /**
16
+ * Launch main.exe, capture stdout/stderr for up to `timeout_seconds`,
17
+ * then kill the process and return the output.
18
+ * Streams lines in real-time via onLine callback.
19
+ */
20
+ export async function crosspadLog(
21
+ timeoutSeconds: number = 5,
22
+ maxLines: number = 200,
23
+ onLine?: OnLine
24
+ ): Promise<LogResult> {
25
+ if (!fs.existsSync(BIN_EXE)) {
26
+ return {
27
+ success: false,
28
+ exe_path: BIN_EXE,
29
+ stdout: "",
30
+ stderr: `${BIN_EXE} not found — build first`,
31
+ exit_code: null,
32
+ duration_seconds: 0,
33
+ truncated: false,
34
+ };
35
+ }
36
+
37
+ onLine?.("stdout", `[crosspad] Launching ${BIN_EXE} (capturing for ${timeoutSeconds}s)...`);
38
+
39
+ const result = await runBuildStream(
40
+ `"${BIN_EXE}"`,
41
+ CROSSPAD_PC_ROOT,
42
+ onLine ?? (() => {}),
43
+ timeoutSeconds * 1000
44
+ );
45
+
46
+ // Truncate to maxLines
47
+ let stdout = result.stdout;
48
+ let stderr = result.stderr;
49
+ let truncated = false;
50
+
51
+ const stdoutLines = stdout.split("\n");
52
+ if (stdoutLines.length > maxLines) {
53
+ stdout = stdoutLines.slice(0, maxLines).join("\n");
54
+ truncated = true;
55
+ }
56
+
57
+ const stderrLines = stderr.split("\n");
58
+ if (stderrLines.length > maxLines) {
59
+ stderr = stderrLines.slice(0, maxLines).join("\n");
60
+ truncated = true;
61
+ }
62
+
63
+ // exitCode -1 = killed by timeout (expected)
64
+ const exitCode = result.exitCode === -1 ? null : result.exitCode;
65
+
66
+ return {
67
+ success: exitCode === 0 || exitCode === null,
68
+ exe_path: BIN_EXE,
69
+ stdout,
70
+ stderr,
71
+ exit_code: exitCode,
72
+ duration_seconds: Math.round(result.durationMs / 100) / 10,
73
+ truncated,
74
+ };
75
+ }
@@ -0,0 +1,75 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { REPOS, CROSSPAD_PC_ROOT } from "../config.js";
4
+ import { getRepoStatus, getSubmodulePin, getHead, RepoStatus } from "../utils/git.js";
5
+
6
+ export interface SubmoduleSync {
7
+ pinned: string | null;
8
+ local_head: string | null;
9
+ in_sync: boolean;
10
+ }
11
+
12
+ export interface ReposStatusResult {
13
+ repos: RepoStatus[];
14
+ crosspad_pc_mode: "dev-mode" | "submodule-mode" | "unknown";
15
+ submodule_sync: Record<string, SubmoduleSync>;
16
+ }
17
+
18
+ function detectMode(): "dev-mode" | "submodule-mode" | "unknown" {
19
+ const corePath = path.join(CROSSPAD_PC_ROOT, "crosspad-core");
20
+ try {
21
+ const stat = fs.lstatSync(corePath);
22
+ // Windows junctions report as symlinks in Node.js
23
+ if (stat.isSymbolicLink()) return "dev-mode";
24
+ // Check for .git file (submodule) or .git dir
25
+ const gitPath = path.join(corePath, ".git");
26
+ if (fs.existsSync(gitPath)) return "submodule-mode";
27
+ return "unknown";
28
+ } catch {
29
+ return "unknown";
30
+ }
31
+ }
32
+
33
+ export function crosspadReposStatus(): ReposStatusResult {
34
+ const repos: RepoStatus[] = [];
35
+
36
+ for (const [name, repoPath] of Object.entries(REPOS)) {
37
+ try {
38
+ if (fs.existsSync(repoPath)) {
39
+ repos.push(getRepoStatus(name, repoPath));
40
+ } else {
41
+ repos.push({
42
+ name,
43
+ path: repoPath,
44
+ branch: "",
45
+ head: "",
46
+ dirtyFiles: [`(repo not found at ${repoPath})`],
47
+ });
48
+ }
49
+ } catch (err: any) {
50
+ repos.push({
51
+ name,
52
+ path: repoPath,
53
+ branch: "",
54
+ head: "",
55
+ dirtyFiles: [`(error: ${err.message})`],
56
+ });
57
+ }
58
+ }
59
+
60
+ const mode = detectMode();
61
+
62
+ // Submodule sync info
63
+ const submoduleSync: Record<string, SubmoduleSync> = {};
64
+ for (const sub of ["crosspad-core", "crosspad-gui"]) {
65
+ const pinned = getSubmodulePin(CROSSPAD_PC_ROOT, sub);
66
+ const localHead = REPOS[sub] ? getHead(REPOS[sub]) : null;
67
+ submoduleSync[sub] = {
68
+ pinned,
69
+ local_head: localHead,
70
+ in_sync: pinned !== null && localHead !== null && localHead.startsWith(pinned),
71
+ };
72
+ }
73
+
74
+ return { repos, crosspad_pc_mode: mode, submodule_sync: submoduleSync };
75
+ }
@@ -0,0 +1,229 @@
1
+ export interface ScaffoldParams {
2
+ name: string; // PascalCase, e.g. "Metronome"
3
+ display_name?: string;
4
+ has_pad_logic?: boolean;
5
+ icon?: string;
6
+ }
7
+
8
+ export interface ScaffoldResult {
9
+ files: Record<string, string>;
10
+ cmake_patch: {
11
+ file: string;
12
+ after_pattern: string;
13
+ content: string;
14
+ };
15
+ }
16
+
17
+ export function crosspadScaffoldApp(params: ScaffoldParams): ScaffoldResult {
18
+ const {
19
+ name,
20
+ display_name = name,
21
+ has_pad_logic = false,
22
+ icon = "CrossPad_Logo_110w.png",
23
+ } = params;
24
+
25
+ const lower = name.toLowerCase();
26
+ const upper = name.toUpperCase();
27
+ const dir = `src/apps/${lower}`;
28
+
29
+ const files: Record<string, string> = {};
30
+
31
+ // --- CMakeLists.txt ---
32
+ const sources = [`\${CMAKE_CURRENT_SOURCE_DIR}/${name}App.cpp`];
33
+ if (has_pad_logic) {
34
+ sources.push(`\${CMAKE_CURRENT_SOURCE_DIR}/${name}PadLogic.cpp`);
35
+ }
36
+
37
+ files[`${dir}/CMakeLists.txt`] = `# ${display_name} app sources
38
+ set(${upper}_APP_SOURCES
39
+ ${sources.join("\n ")}
40
+ PARENT_SCOPE
41
+ )
42
+ `;
43
+
44
+ // --- App header ---
45
+ files[`${dir}/${name}App.hpp`] = `#pragma once
46
+
47
+ #include "lvgl.h"
48
+
49
+ class App;
50
+
51
+ lv_obj_t* ${name}_create(lv_obj_t* parent, App* app);
52
+ void ${name}_destroy(lv_obj_t* app_obj);
53
+ `;
54
+
55
+ // --- App implementation ---
56
+ let appCpp = `/**
57
+ * @file ${name}App.cpp
58
+ * @brief ${display_name} app — LVGL GUI
59
+ */
60
+
61
+ #include "${name}App.hpp"
62
+ #include "pc_stubs/PcApp.hpp"
63
+ #include "pc_stubs/pc_platform.h"
64
+
65
+ #include <crosspad/app/AppRegistry.hpp>
66
+ #include <crosspad/pad/PadManager.hpp>
67
+ #include "crosspad-gui/components/app_lifecycle.h"
68
+ #include "crosspad-gui/platform/IGuiPlatform.h"
69
+ #include "crosspad_app.hpp"
70
+
71
+ #include "lvgl.h"
72
+
73
+ #include <cstdio>
74
+ `;
75
+
76
+ if (has_pad_logic) {
77
+ appCpp += `#include "${name}PadLogic.hpp"
78
+ #include <memory>
79
+
80
+ static std::shared_ptr<${name}PadLogic> s_padLogic;
81
+ `;
82
+ }
83
+
84
+ appCpp += `
85
+ static App* s_app = nullptr;
86
+
87
+ /* ── App create / destroy ────────────────────────────────────────────── */
88
+
89
+ lv_obj_t* ${name}_create(lv_obj_t* parent, App* a)
90
+ {
91
+ s_app = a;
92
+ lv_obj_t* cont = lv_obj_create(parent);
93
+ lv_obj_set_size(cont, lv_pct(100), lv_pct(100));
94
+ lv_obj_set_style_bg_color(cont, lv_color_black(), 0);
95
+ lv_obj_set_style_bg_opa(cont, LV_OPA_COVER, 0);
96
+ lv_obj_set_style_pad_all(cont, 4, 0);
97
+ lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN);
98
+ lv_obj_set_style_pad_row(cont, 2, 0);
99
+ lv_obj_remove_flag(cont, LV_OBJ_FLAG_SCROLLABLE);
100
+
101
+ /* ── Title bar with close button ─────────────────────────── */
102
+ lv_obj_t* titleBar = lv_obj_create(cont);
103
+ lv_obj_set_size(titleBar, lv_pct(100), 24);
104
+ lv_obj_set_style_bg_opa(titleBar, LV_OPA_TRANSP, 0);
105
+ lv_obj_set_style_border_width(titleBar, 0, 0);
106
+ lv_obj_set_style_pad_all(titleBar, 0, 0);
107
+ lv_obj_remove_flag(titleBar, LV_OBJ_FLAG_SCROLLABLE);
108
+
109
+ lv_obj_t* titleLabel = lv_label_create(titleBar);
110
+ lv_label_set_text(titleLabel, "${display_name}");
111
+ lv_obj_set_style_text_color(titleLabel, lv_color_white(), 0);
112
+ lv_obj_set_style_text_font(titleLabel, &lv_font_montserrat_14, 0);
113
+ lv_obj_align(titleLabel, LV_ALIGN_LEFT_MID, 4, 0);
114
+
115
+ lv_obj_t* closeBtn = lv_button_create(titleBar);
116
+ lv_obj_set_size(closeBtn, 28, 20);
117
+ lv_obj_align(closeBtn, LV_ALIGN_RIGHT_MID, -2, 0);
118
+ lv_obj_set_style_bg_color(closeBtn, lv_color_hex(0x662222), 0);
119
+ lv_obj_set_style_bg_color(closeBtn, lv_color_hex(0xAA3333), LV_STATE_PRESSED);
120
+ lv_obj_set_style_radius(closeBtn, 4, 0);
121
+ lv_obj_set_style_shadow_width(closeBtn, 0, 0);
122
+ lv_obj_t* closeLbl = lv_label_create(closeBtn);
123
+ lv_label_set_text(closeLbl, "X");
124
+ lv_obj_set_style_text_font(closeLbl, &lv_font_montserrat_12, 0);
125
+ lv_obj_center(closeLbl);
126
+ lv_obj_add_event_cb(closeBtn, [](lv_event_t*) {
127
+ if (s_app) crosspad_gui::app_request_close(s_app);
128
+ }, LV_EVENT_CLICKED, nullptr);
129
+ `;
130
+
131
+ if (has_pad_logic) {
132
+ appCpp += `
133
+ /* ── Register pad logic ──────────────────────────────────── */
134
+ s_padLogic = std::make_shared<${name}PadLogic>();
135
+ crosspad::getPadManager().registerPadLogic("${name}", s_padLogic);
136
+
137
+ if (a) {
138
+ a->setOnShow([](lv_obj_t*) {
139
+ crosspad::getPadManager().setActivePadLogic("${name}");
140
+ crosspad_app_update_pad_icon();
141
+ });
142
+ a->setOnHide([](lv_obj_t*) {
143
+ crosspad::getPadManager().setActivePadLogic("");
144
+ crosspad_app_update_pad_icon();
145
+ });
146
+ }
147
+ `;
148
+ }
149
+
150
+ appCpp += `
151
+ /* ── TODO: Add your UI here ──────────────────────────────── */
152
+
153
+ printf("[${name}] App created\\n");
154
+ return cont;
155
+ }
156
+
157
+ void ${name}_destroy(lv_obj_t* app_obj)
158
+ {
159
+ `;
160
+
161
+ if (has_pad_logic) {
162
+ appCpp += ` crosspad::getPadManager().setActivePadLogic("");
163
+ crosspad::getPadManager().unregisterPadLogic("${name}");
164
+ crosspad_app_update_pad_icon();
165
+ s_padLogic.reset();
166
+ `;
167
+ }
168
+
169
+ appCpp += ` s_app = nullptr;
170
+ lv_obj_delete_async(app_obj);
171
+ printf("[${name}] App destroyed\\n");
172
+ }
173
+
174
+ /* ── App registration ────────────────────────────────────────────────── */
175
+
176
+ void _register_${name}_app() {
177
+ static char icon_path[256];
178
+ snprintf(icon_path, sizeof(icon_path), "%s${icon}",
179
+ crosspad_gui::getGuiPlatform().assetPathPrefix());
180
+
181
+ static const crosspad::AppEntry entry = {
182
+ "${name}", icon_path, ${name}_create, ${name}_destroy,
183
+ nullptr, nullptr, nullptr, nullptr, 0
184
+ };
185
+ crosspad::AppRegistry::getInstance().registerApp(entry);
186
+ }
187
+ `;
188
+
189
+ files[`${dir}/${name}App.cpp`] = appCpp;
190
+
191
+ // --- Pad logic (optional) ---
192
+ if (has_pad_logic) {
193
+ files[`${dir}/${name}PadLogic.hpp`] = `#pragma once
194
+
195
+ #include <crosspad/pad/IPadLogicHandler.hpp>
196
+
197
+ class ${name}PadLogic : public crosspad::IPadLogicHandler {
198
+ public:
199
+ void onPadPressed(uint8_t padIndex, uint8_t velocity) override;
200
+ void onPadReleased(uint8_t padIndex) override;
201
+ };
202
+ `;
203
+
204
+ files[`${dir}/${name}PadLogic.cpp`] = `#include "${name}PadLogic.hpp"
205
+ #include <cstdio>
206
+
207
+ void ${name}PadLogic::onPadPressed(uint8_t padIndex, uint8_t velocity)
208
+ {
209
+ printf("[${name}PadLogic] Pad %d pressed (vel=%d)\\n", padIndex, velocity);
210
+ // TODO: Implement pad press logic
211
+ }
212
+
213
+ void ${name}PadLogic::onPadReleased(uint8_t padIndex)
214
+ {
215
+ printf("[${name}PadLogic] Pad %d released\\n", padIndex);
216
+ // TODO: Implement pad release logic
217
+ }
218
+ `;
219
+ }
220
+
221
+ // --- CMake patch instructions ---
222
+ const cmakePatch = {
223
+ file: "CMakeLists.txt",
224
+ after_pattern: "add_subdirectory(src/apps/",
225
+ content: `add_subdirectory(${dir})\nlist(APPEND MAIN_SOURCES \${${upper}_APP_SOURCES})`,
226
+ };
227
+
228
+ return { files, cmake_patch: cmakePatch };
229
+ }