#!/usr/bin/env python3 """ Improved build helper CLI. This version addresses Windows quoting issues with the `msvc-quick` command and provides a cleaner approach for executing `vcvarsall.bat` followed by a CMake build. The rest of the commands remain platform-neutral and avoid using `shell=True` wherever possible for greater safety and consistency. Key changes: * Simplified the construction of the one-liner used to call `vcvarsall.bat` and run the subsequent command, avoiding nested quoting that caused errors under PowerShell. The `cmd.exe` invocation now looks like: cmd.exe /d /s /c call "" && where `` is properly quoted using `subprocess.list2cmdline`. * Updated `msvc-quick` to use the above construction, while still allowing users to override the follow-on command via positional arguments after `--`. * Other commands continue to build argument lists rather than shell strings, preventing injection and ensuring predictable behavior across platforms. * Added descriptive comments throughout for maintainability. Use this script as a drop-in replacement for the original `dev_commands.py`. """ from __future__ import annotations import argparse import os import platform import subprocess from pathlib import Path from typing import Iterable, Sequence IS_WINDOWS = platform.system() == "Windows" DEFAULT_GENERATOR = "ninja-msvc" if IS_WINDOWS else "ninja" GENERATOR_DEFAULT_DIR = { "vs": "build", "ninja": "build-ninja", "ninja-msvc": "build-ninja-msvc", } CMAKE_GENERATOR = { "vs": "Visual Studio 17 2022", "ninja": "Ninja", "ninja-msvc": "Ninja", } DEFAULT_BUILD_DIR = GENERATOR_DEFAULT_DIR[DEFAULT_GENERATOR] TRACE_ENV_VAR = "DEV_COMMANDS_TRACE" DEFAULT_VCVARSALL = ( "C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional" "\\VC\\Auxiliary\\Build\\vcvarsall.bat" ) def _sh_quote(s: str) -> str: """Minimal POSIX-style quoting for display purposes on non-Windows.""" if not s: return "''" safe = set( "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" "._-/:@=+" ) if all(c in safe for c in s): return s return "'" + s.replace("'", "'\"'\"'") + "'" def _print_cmd(argv: Sequence[str]) -> None: """ Print a command list in a way that approximates how it would appear on the command line. Uses Windows-specific quoting on Windows via `subprocess.list2cmdline`, and POSIX-style quoting elsewhere. """ if IS_WINDOWS: rendered = subprocess.list2cmdline(list(argv)) else: rendered = " ".join(_sh_quote(a) for a in argv) print("\n> " + rendered) def _trace(message: str) -> None: if os.environ.get(TRACE_ENV_VAR) == "1": print(f"[trace] {message}") def _strip_leading_double_dash(args: Sequence[str] | None) -> list[str]: """Drop a leading `--` that argparse keeps with REMAINDER arguments.""" if not args: return [] args_list = list(args) if args_list and args_list[0] == "--": return args_list[1:] return args_list def _has_runtime_config_arg(args: Sequence[str] | None) -> bool: if not args: return False for arg in args: if arg in {"-j", "--json-file-in"}: return True if arg.startswith("--json-file-in="): return True if arg.startswith("-j") and len(arg) > 2: return True return False def run_argvs(argvs: Iterable[Sequence[str]], dry_run: bool, cwd: str | None = None) -> None: """ Run a sequence of commands represented as lists of arguments. Each command is printed before execution. If `dry_run` is True, commands are printed but not executed. """ for argv in argvs: _print_cmd(argv) if dry_run: continue subprocess.run(list(argv), check=True, cwd=cwd) def _as_build_dir(path_str: str | None, fallback: str) -> str: """Return the provided path if not None, otherwise the fallback.""" return path_str or fallback def _has_cache_arg(cmake_args: Sequence[str] | None, name: str) -> bool: """Return True if the CMake args already define a cache variable.""" if not cmake_args: return False key = f"-D{name}" prefix = f"-D{name}=" for arg in cmake_args: if arg == key or arg.startswith(prefix): return True return False def _find_conan_toolchain(build_type: str) -> Path | None: """ Look for the Conan toolchain file in common output locations. The default `conan install -of build` + `cmake_layout()` layout produces `build/build//generators/conan_toolchain.cmake`. """ candidates = [ Path("build") / "build" / build_type / "generators" / "conan_toolchain.cmake", Path("build") / build_type / "generators" / "conan_toolchain.cmake", Path("build") / "generators" / "conan_toolchain.cmake", Path("build") / "conan_toolchain.cmake", ] for candidate in candidates: if candidate.is_file(): return candidate.resolve() return None def _has_cmake_cache(build_dir: str) -> bool: """Return True if the build directory already has a CMake cache.""" return (Path(build_dir) / "CMakeCache.txt").is_file() def dependencies(args: argparse.Namespace) -> None: """Run Conan profile detection and install dependencies.""" cmd_detect = ["conan", "profile", "detect", "-f"] cmd_install = ["conan", "install", ".", "-of", "build", "-b", "missing"] conan_install_args = _strip_leading_double_dash(args.conan_install_args) if conan_install_args: cmd_install.extend(conan_install_args) run_argvs([cmd_detect, cmd_install], args.dry_run) def configure(args: argparse.Namespace) -> None: """Configure a CMake project based on the chosen generator and options.""" if args.preset: cmake_args = ["cmake", "--preset", args.preset] cmake_extra_args = _strip_leading_double_dash(args.cmake_args) if cmake_extra_args: cmake_args.extend(cmake_extra_args) run_argvs([cmake_args], args.dry_run) return generator = args.generator or DEFAULT_GENERATOR build_dir = _as_build_dir( args.build_dir, GENERATOR_DEFAULT_DIR.get(generator, "build") ) cmake_args: list[str] = ["cmake", "-B", build_dir, "-S", "."] conan_toolchain = _find_conan_toolchain(args.build_type) if ( conan_toolchain and not _has_cache_arg(args.cmake_args, "CMAKE_TOOLCHAIN_FILE") and not _has_cmake_cache(build_dir) ): cmake_args.append(f"-DCMAKE_TOOLCHAIN_FILE={conan_toolchain}") if conan_toolchain and not _has_cache_arg(args.cmake_args, "CMAKE_PREFIX_PATH"): conan_generators_dir = conan_toolchain.parent cmake_args.append(f"-DCMAKE_PREFIX_PATH={conan_generators_dir}") if generator == "vs": cmake_args.extend(["-G", CMAKE_GENERATOR["vs"]]) else: cmake_args.extend(["-G", CMAKE_GENERATOR[generator]]) cmake_args.append(f"-DCMAKE_BUILD_TYPE={args.build_type}") cmake_extra_args = _strip_leading_double_dash(args.cmake_args) if cmake_extra_args: cmake_args.extend(cmake_extra_args) run_argvs([cmake_args], args.dry_run) def build(args: argparse.Namespace) -> None: """Run the `cmake --build` command for a given build directory.""" cmd: list[str] = ["cmake", "--build", args.build_dir] if args.config: cmd.extend(["--config", args.config]) if args.target: cmd.extend(["--target", args.target]) build_tool_args = _strip_leading_double_dash(args.build_tool_args) if build_tool_args: cmd.append("--") cmd.extend(build_tool_args) run_argvs([cmd], args.dry_run) def _cmd_one_liner_vcvars_then(bat: str, arch: str, then_parts: Sequence[str]) -> list[str]: """ Construct a command to call a Visual Studio environment setup batch file and then run another command. The returned list of arguments can be passed to subprocess.run with shell=False. On Windows, we use: cmd.exe /d /s /c call "" && The path to the batch file is quoted to handle spaces. The follow-on command (`then_parts`) is converted to a command string using `subprocess.list2cmdline`, which properly quotes arguments for cmd.exe. """ then_cmdline = subprocess.list2cmdline(list(then_parts)) full_cmd = f'call "{bat}" {arch} && {then_cmdline}' return ["cmd.exe", "/d", "/s", "/c", full_cmd] def msvc_quick(args: argparse.Namespace) -> None: """ Set up the Visual Studio environment and build the project. On Windows, this command calls `vcvarsall.bat` (or a custom batch file) with the specified architecture, then runs a follow-on command. By default, the follow-on command is `cmake --build ` with optional configuration, target, and extra build-tool arguments. Users can override the follow-on command entirely by specifying positional arguments after `--`. On non-Windows platforms, this command will exit with an error, as there is no Visual Studio environment to initialize. """ if not IS_WINDOWS: raise SystemExit("msvc-quick is only supported on Windows") bat = args.bat_path or DEFAULT_VCVARSALL arch = args.arch or "x64" if args.then_command: then_cmd = _strip_leading_double_dash(args.then_command) else: build_dir = _as_build_dir(args.build_dir, DEFAULT_BUILD_DIR) then_cmd = ["cmake", "--build", build_dir] if args.config: then_cmd.extend(["--config", args.config]) if args.target: then_cmd.extend(["--target", args.target]) build_tool_args = _strip_leading_double_dash(args.build_tool_args) if build_tool_args: then_cmd.append("--") then_cmd.extend(build_tool_args) cmd = _cmd_one_liner_vcvars_then(bat, arch, then_cmd) run_argvs([cmd], args.dry_run) def _sync_assets(build_dir: str, dry_run: bool) -> None: """ Sync asset files (scripts, shaders, models) from the project root to the build directory before running the application. """ import shutil build_path = Path(build_dir) project_root = Path(".") # Define asset directories to sync asset_dirs = [ ("scripts", ["*.lua"]), ("shaders", ["*.vert", "*.frag", "*.geom", "*.tesc", "*.tese", "*.comp", "*.spv"]), ("scripts/models", ["*.stl", "*.obj", "*.fbx"]), ("config", ["*.json"]), ] print("\n=== Syncing Assets ===") for src_dir, patterns in asset_dirs: src_path = project_root / src_dir dst_path = build_path / src_dir if not src_path.exists(): continue # Create destination directory if needed if not dry_run: dst_path.mkdir(parents=True, exist_ok=True) # Sync files matching patterns for pattern in patterns: for src_file in src_path.glob(pattern): if src_file.is_file() and src_file.name != "dev_commands.py": dst_file = dst_path / src_file.name print(f" {src_file} -> {dst_file}") if not dry_run: shutil.copy2(src_file, dst_file) print("=== Assets Synced ===\n") def run_demo(args: argparse.Namespace) -> None: """ Run a compiled demo application from the build directory. The default executable is `sdl3_app` (or `sdl3_app.exe` on Windows). Additional arguments can be passed to the executable after `--`. By default, syncs asset files before running. Use --no-sync to skip asset synchronization. """ build_dir = _as_build_dir(args.build_dir, DEFAULT_BUILD_DIR) if not args.no_sync: _sync_assets(build_dir, args.dry_run) exe_name = args.target or ("sdl3_app.exe" if IS_WINDOWS else "sdl3_app") binary = str(Path(build_dir).resolve() / exe_name) run_args = _strip_leading_double_dash(args.args) cmd: list[str] = [binary] if run_args: cmd.extend(run_args) _print_cmd(cmd) import os os.chdir(build_dir) os.execv(binary, cmd) def gui(args: argparse.Namespace) -> None: """ Launch a PyQt6 GUI launcher similar to Steam's interface for managing builds and running demos. """ try: from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QTextEdit, QComboBox, QListWidget, QListWidgetItem, QSplitter, QMenuBar, QDialog, QDialogButtonBox, QFormLayout, QMessageBox, QPlainTextEdit, QTabWidget, QLineEdit ) from PyQt6.QtCore import Qt, QProcess, QSize, QTimer from PyQt6.QtGui import QFont, QPalette, QColor, QAction, QSyntaxHighlighter, QTextCharFormat, QTextCursor, QTextDocument except ImportError: raise SystemExit( "PyQt6 is not installed. Install it with:\n" " pip install PyQt6" ) import sys import re class LuaSyntaxHighlighter(QSyntaxHighlighter): """Simple Lua syntax highlighter""" def __init__(self, parent=None): super().__init__(parent) # Define colors keyword_color = QColor("#569cd6") # Blue string_color = QColor("#ce9178") # Orange comment_color = QColor("#6a9955") # Green function_color = QColor("#dcdcaa") # Yellow number_color = QColor("#b5cea8") # Light green # Define formatting self.keyword_format = QTextCharFormat() self.keyword_format.setForeground(keyword_color) self.keyword_format.setFontWeight(700) self.string_format = QTextCharFormat() self.string_format.setForeground(string_color) self.comment_format = QTextCharFormat() self.comment_format.setForeground(comment_color) self.comment_format.setFontItalic(True) self.function_format = QTextCharFormat() self.function_format.setForeground(function_color) self.number_format = QTextCharFormat() self.number_format.setForeground(number_color) # Lua keywords keywords = [ 'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', 'function', 'if', 'in', 'local', 'nil', 'not', 'or', 'repeat', 'return', 'then', 'true', 'until', 'while' ] # Build highlighting rules self.rules = [] # Keywords for word in keywords: pattern = f'\\b{word}\\b' self.rules.append((re.compile(pattern), self.keyword_format)) # Numbers self.rules.append((re.compile(r'\b\d+\.?\d*\b'), self.number_format)) # Functions self.rules.append((re.compile(r'\b[A-Za-z_][A-Za-z0-9_]*(?=\s*\()'), self.function_format)) # Strings (double quotes) self.rules.append((re.compile(r'"[^"\\]*(\\.[^"\\]*)*"'), self.string_format)) # Strings (single quotes) self.rules.append((re.compile(r"'[^'\\]*(\\.[^'\\]*)*'"), self.string_format)) # Comments self.rules.append((re.compile(r'--[^\n]*'), self.comment_format)) def highlightBlock(self, text): """Apply syntax highlighting to the given block of text""" for pattern, fmt in self.rules: for match in pattern.finditer(text): start, end = match.span() self.setFormat(start, end - start, fmt) class BuildSettingsDialog(QDialog): """Dialog for configuring build settings""" def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Build Settings") self.setMinimumWidth(400) layout = QFormLayout(self) self.preset_combo = QComboBox() self.preset_combo.addItems(["default", "vita-release"]) self.preset_combo.setCurrentText("default") layout.addRow("Preset:", self.preset_combo) self.generator_combo = QComboBox() self.generator_combo.addItems(["ninja", "ninja-msvc", "vs"]) self.generator_combo.setCurrentText(DEFAULT_GENERATOR) layout.addRow("Generator:", self.generator_combo) self.build_type_combo = QComboBox() self.build_type_combo.addItems(["Release", "Debug", "RelWithDebInfo"]) layout.addRow("Build Type:", self.build_type_combo) self.target_combo = QComboBox() self.target_combo.addItems(["sdl3_app", "all"]) layout.addRow("Target:", self.target_combo) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addRow(buttons) class NewProjectDialog(QDialog): """Dialog for creating a new project from templates""" def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("New Project") self.setMinimumWidth(500) self.setMinimumHeight(300) layout = QVBoxLayout(self) # Project name input name_layout = QHBoxLayout() name_label = QLabel("Project Name:") self.name_input = QLineEdit() self.name_input.setPlaceholderText("Enter project name") name_layout.addWidget(name_label) name_layout.addWidget(self.name_input) layout.addLayout(name_layout) # Template selection template_label = QLabel("Template:") layout.addWidget(template_label) self.template_combo = QComboBox() self.template_combo.addItem("Cube Demo - 3D game with physics", "cube") self.template_combo.addItem("GUI Demo - ImGui interface demo", "gui") self.template_combo.addItem("Soundboard - Audio application", "soundboard") layout.addWidget(self.template_combo) # Description self.description_label = QLabel() self.description_label.setWordWrap(True) self.description_label.setStyleSheet("color: #666; font-size: 10pt; margin-top: 10px;") layout.addWidget(self.description_label) # Update description when template changes self.template_combo.currentIndexChanged.connect(self.update_description) self.update_description() layout.addStretch() # Buttons buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) def update_description(self): """Update description based on selected template""" template = self.template_combo.currentData() descriptions = { "cube": "A 3D cube room with first-person controls, lanterns, and physics interactions. Includes mouse look, movement controls, and audio.", "gui": "An interactive GUI demonstration using ImGui widgets and controls. Showcases various UI elements and interactions.", "soundboard": "An audio soundboard application for playing and mixing sound effects. Features multiple audio channels and controls." } self.description_label.setText(descriptions.get(template, "")) def get_project_data(self): """Return the project name and template type""" return { "name": self.name_input.text().strip(), "template": self.template_combo.currentData() } class BuildLauncherGUI(QMainWindow): def __init__(self): super().__init__() self.process = None self.current_game = None # Build settings self.preset = "default" self.generator = DEFAULT_GENERATOR self.build_type = "Release" self.target = "sdl3_app" # Load games from config files self.games = self.load_games_from_config() self.init_ui() def load_games_from_config(self): """Scan config directory for JSON files with launcher metadata""" import json from pathlib import Path games = [] config_dir = Path("config") if not config_dir.exists(): # Fallback if config dir doesn't exist return [{ "id": "sdl3_app", "name": "SDL3 Demo Application", "description": "Main SDL3 demo application showcasing basic functionality", "config_file": "config/seed_runtime.json", }] # Scan all JSON files in config directory for config_file in sorted(config_dir.glob("*.json")): try: with open(config_file, 'r') as f: data = json.load(f) # Check if file has launcher metadata if "launcher" in data: launcher = data["launcher"] # Only add if enabled (defaults to True if not specified) if launcher.get("enabled", True): games.append({ "id": config_file.stem, "name": launcher.get("name", config_file.stem), "description": launcher.get("description", ""), "config_file": str(config_file), }) except (json.JSONDecodeError, IOError) as e: # Skip files that can't be parsed print(f"Warning: Could not load {config_file}: {e}") continue # If no games found, add a default one if not games: games.append({ "id": "sdl3_app", "name": "SDL3 Demo Application", "description": "Main SDL3 demo application showcasing basic functionality", "config_file": "config/seed_runtime.json", }) return games def init_ui(self): self.setWindowTitle("SDL3 C++ Launcher") self.setMinimumSize(1000, 700) # Set dark theme similar to Steam self.set_dark_theme() # Central widget with horizontal splitter central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QHBoxLayout(central_widget) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) # Left sidebar - Game library sidebar = QWidget() sidebar.setMaximumWidth(250) sidebar.setStyleSheet("background-color: #171a21;") sidebar_layout = QVBoxLayout(sidebar) sidebar_layout.setContentsMargins(0, 0, 0, 0) # Library header library_header = QLabel("LIBRARY") library_header.setStyleSheet(""" background-color: #1b2838; color: #c6d1db; padding: 12px; font-weight: bold; font-size: 11pt; """) sidebar_layout.addWidget(library_header) # Game list self.game_list = QListWidget() self.game_list.setStyleSheet(""" QListWidget { background-color: #171a21; border: none; color: #c6d1db; font-size: 10pt; outline: none; } QListWidget::item { padding: 12px; border-bottom: 1px solid #0e1216; } QListWidget::item:selected { background-color: #2a475e; } QListWidget::item:hover { background-color: #1b2838; } """) self.game_list.currentItemChanged.connect(self.on_game_selected) for game in self.games: item = QListWidgetItem(game["name"]) item.setData(Qt.ItemDataRole.UserRole, game) self.game_list.addItem(item) sidebar_layout.addWidget(self.game_list) main_layout.addWidget(sidebar) # Right side - Game details and console right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(0) # Game detail header detail_header = QWidget() detail_header.setStyleSheet("background-color: #1b2838;") detail_header.setMinimumHeight(200) detail_layout = QVBoxLayout(detail_header) detail_layout.setContentsMargins(30, 30, 30, 30) # Game title self.game_title = QLabel("Select a game") title_font = QFont() title_font.setPointSize(24) title_font.setBold(True) self.game_title.setFont(title_font) self.game_title.setStyleSheet("color: #ffffff;") detail_layout.addWidget(self.game_title) # Game description self.game_description = QLabel("") self.game_description.setWordWrap(True) self.game_description.setStyleSheet("color: #8f98a0; font-size: 11pt;") detail_layout.addWidget(self.game_description) # Play button container button_container = QHBoxLayout() self.play_btn = QPushButton("ā–¶ PLAY") self.play_btn.setEnabled(False) self.play_btn.setMinimumHeight(50) self.play_btn.setMinimumWidth(200) play_font = QFont() play_font.setPointSize(14) play_font.setBold(True) self.play_btn.setFont(play_font) self.play_btn.setStyleSheet(""" QPushButton { background-color: #5c7e10; color: white; border: none; border-radius: 4px; padding: 10px 40px; } QPushButton:hover { background-color: #6a9612; } QPushButton:pressed { background-color: #4a6a0d; } QPushButton:disabled { background-color: #3f4e5f; color: #7a8896; } """) self.play_btn.clicked.connect(self.play_game) button_container.addWidget(self.play_btn) self.stop_btn = QPushButton("ā¹ STOP") self.stop_btn.setEnabled(False) self.stop_btn.setMinimumHeight(50) self.stop_btn.setMinimumWidth(120) stop_font = QFont() stop_font.setPointSize(12) stop_font.setBold(True) self.stop_btn.setFont(stop_font) self.stop_btn.setStyleSheet(""" QPushButton { background-color: #a83232; color: white; border: none; border-radius: 4px; padding: 10px 20px; } QPushButton:hover { background-color: #c13838; } QPushButton:pressed { background-color: #8f2828; } QPushButton:disabled { background-color: #3f4e5f; color: #7a8896; } """) self.stop_btn.clicked.connect(self.stop_process) button_container.addWidget(self.stop_btn) button_container.addStretch() detail_layout.addLayout(button_container) right_layout.addWidget(detail_header) # Tabbed interface for code editor and console self.tab_widget = QTabWidget() self.tab_widget.setStyleSheet(""" QTabWidget::pane { background-color: #1b2838; border: none; } QTabBar::tab { background-color: #1b2838; color: #8f98a0; padding: 8px 16px; border: none; margin-right: 2px; } QTabBar::tab:selected { background-color: #2a475e; color: #c6d1db; } QTabBar::tab:hover { background-color: #243447; } """) # Lua Code Editor Tab editor_container = QWidget() editor_layout = QVBoxLayout(editor_container) editor_layout.setContentsMargins(10, 10, 10, 10) # Editor toolbar editor_toolbar = QHBoxLayout() self.lua_file_label = QLabel("No file loaded") self.lua_file_label.setStyleSheet("color: #8f98a0; font-size: 9pt;") editor_toolbar.addWidget(self.lua_file_label) # Search box self.search_edit = QLineEdit() self.search_edit.setPlaceholderText("Search...") self.search_edit.setMaximumWidth(150) self.search_edit.setStyleSheet(""" QLineEdit { background-color: #2a475e; color: #c6d1db; border: 1px solid #0e1216; border-radius: 3px; padding: 3px; } """) self.search_edit.returnPressed.connect(lambda: self.search_in_editor(forward=True)) editor_toolbar.addWidget(self.search_edit) self.find_prev_btn = QPushButton("⬆ Prev") self.find_prev_btn.setStyleSheet(""" QPushButton { background-color: #2a475e; color: #c6d1db; border: none; border-radius: 3px; padding: 5px; } QPushButton:hover { background-color: #3e5c78; } """) self.find_prev_btn.clicked.connect(lambda: self.search_in_editor(forward=False)) editor_toolbar.addWidget(self.find_prev_btn) self.find_next_btn = QPushButton("⬇ Next") self.find_next_btn.setStyleSheet(""" QPushButton { background-color: #2a475e; color: #c6d1db; border: none; border-radius: 3px; padding: 5px; } QPushButton:hover { background-color: #3e5c78; } """) self.find_next_btn.clicked.connect(lambda: self.search_in_editor(forward=True)) editor_toolbar.addWidget(self.find_next_btn) # Status label for feedback self.status_label = QLabel("") self.status_label.setStyleSheet("color: #8f98a0; font-size: 9pt;") editor_toolbar.addWidget(self.status_label) editor_toolbar.addStretch() self.save_lua_btn = QPushButton("šŸ’¾ Save") self.save_lua_btn.setEnabled(False) self.save_lua_btn.setStyleSheet(""" QPushButton { background-color: #2a475e; color: #c6d1db; border: none; border-radius: 3px; padding: 5px 15px; } QPushButton:hover { background-color: #3e5c78; } QPushButton:disabled { background-color: #1b2838; color: #4e5a66; } """) self.save_lua_btn.clicked.connect(self.save_lua_script) editor_toolbar.addWidget(self.save_lua_btn) self.reload_lua_btn = QPushButton("šŸ”„ Reload") self.reload_lua_btn.setEnabled(False) self.reload_lua_btn.setStyleSheet(""" QPushButton { background-color: #2a475e; color: #c6d1db; border: none; border-radius: 3px; padding: 5px 15px; } QPushButton:hover { background-color: #3e5c78; } QPushButton:disabled { background-color: #1b2838; color: #4e5a66; } """) self.reload_lua_btn.clicked.connect(self.reload_lua_script) editor_toolbar.addWidget(self.reload_lua_btn) editor_layout.addLayout(editor_toolbar) # Code editor self.lua_editor = QPlainTextEdit() self.lua_editor.setPlaceholderText("Select a game to edit its Lua script...") editor_font = QFont("Courier New") editor_font.setPointSize(10) self.lua_editor.setFont(editor_font) self.lua_editor.setStyleSheet(""" QPlainTextEdit { background-color: #1e1e1e; color: #d4d4d4; border: 1px solid #0e1216; border-radius: 3px; padding: 5px; } """) self.lua_editor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) self.lua_editor.textChanged.connect(self.on_lua_text_changed) # Apply syntax highlighting self.lua_highlighter = LuaSyntaxHighlighter(self.lua_editor.document()) editor_layout.addWidget(self.lua_editor) self.tab_widget.addTab(editor_container, "Lua Script Editor") # Console Output Tab console_container = QWidget() console_layout = QVBoxLayout(console_container) console_layout.setContentsMargins(10, 10, 10, 10) console_label = QLabel("OUTPUT") console_label.setStyleSheet("color: #8f98a0; font-weight: bold; font-size: 9pt;") console_layout.addWidget(console_label) self.console = QTextEdit() self.console.setReadOnly(True) console_font = QFont("Courier New") console_font.setPointSize(9) self.console.setFont(console_font) self.console.setStyleSheet(""" QTextEdit { background-color: #0e1216; color: #c6d1db; border: 1px solid #0e1216; border-radius: 3px; } """) # Console toolbar console_toolbar = QHBoxLayout() self.copy_console_btn = QPushButton("šŸ“‹ Copy") self.copy_console_btn.setStyleSheet(""" QPushButton { background-color: #2a475e; color: #c6d1db; border: none; border-radius: 3px; padding: 5px 15px; } QPushButton:hover { background-color: #3e5c78; } """) self.copy_console_btn.clicked.connect(self.copy_console) console_toolbar.addWidget(self.copy_console_btn) self.clear_console_btn = QPushButton("šŸ—‘ Clear") self.clear_console_btn.setStyleSheet(""" QPushButton { background-color: #2a475e; color: #c6d1db; border: none; border-radius: 3px; padding: 5px 15px; } QPushButton:hover { background-color: #3e5c78; } """) self.clear_console_btn.clicked.connect(self.console.clear) console_toolbar.addWidget(self.clear_console_btn) console_toolbar.addStretch() console_layout.addLayout(console_toolbar) console_layout.addWidget(self.console) self.tab_widget.addTab(console_container, "Console Output") right_layout.addWidget(self.tab_widget) main_layout.addWidget(right_panel, 1) # Create menu bar after all widgets are initialized self.create_menu_bar() # Select first game by default if self.games: self.game_list.setCurrentRow(0) def create_menu_bar(self): """Create menu bar with developer tools""" menubar = self.menuBar() menubar.setStyleSheet(""" QMenuBar { background-color: #171a21; color: #c6d1db; padding: 4px; } QMenuBar::item:selected { background-color: #2a475e; } QMenu { background-color: #1b2838; color: #c6d1db; border: 1px solid #0e1216; } QMenu::item:selected { background-color: #2a475e; } """) # File menu file_menu = menubar.addMenu("File") new_action = QAction("New Project...", self) new_action.triggered.connect(self.show_new_project_dialog) file_menu.addAction(new_action) file_menu.addSeparator() exit_action = QAction("Exit", self) exit_action.triggered.connect(self.close) file_menu.addAction(exit_action) # Developer menu dev_menu = menubar.addMenu("Developer") deps_action = QAction("Install Dependencies", self) deps_action.triggered.connect(self.run_dependencies) dev_menu.addAction(deps_action) config_action = QAction("Configure CMake", self) config_action.triggered.connect(self.run_configure) dev_menu.addAction(config_action) build_action = QAction("Build Project", self) build_action.triggered.connect(self.run_build) dev_menu.addAction(build_action) sync_action = QAction("Sync Assets", self) sync_action.triggered.connect(self.sync_assets) dev_menu.addAction(sync_action) dev_menu.addSeparator() settings_action = QAction("Build Settings...", self) settings_action.triggered.connect(self.show_settings) dev_menu.addAction(settings_action) # View menu view_menu = menubar.addMenu("View") clear_console_action = QAction("Clear Console", self) clear_console_action.triggered.connect(self.console.clear) view_menu.addAction(clear_console_action) def show_settings(self): """Show build settings dialog""" dialog = BuildSettingsDialog(self) dialog.preset_combo.setCurrentText(self.preset) dialog.generator_combo.setCurrentText(self.generator) dialog.build_type_combo.setCurrentText(self.build_type) dialog.target_combo.setCurrentText(self.target) if dialog.exec() == QDialog.DialogCode.Accepted: self.preset = dialog.preset_combo.currentText() self.generator = dialog.generator_combo.currentText() self.build_type = dialog.build_type_combo.currentText() self.target = dialog.target_combo.currentText() self.log(f"Settings updated: Preset={self.preset}, Generator={self.generator}, Build Type={self.build_type}, Target={self.target}") def show_new_project_dialog(self): """Show new project creation dialog""" dialog = NewProjectDialog(self) if dialog.exec() == QDialog.DialogCode.Accepted: project_data = dialog.get_project_data() if project_data["name"]: self.create_new_project(project_data["name"], project_data["template"]) else: QMessageBox.warning(self, "Invalid Name", "Please enter a valid project name.") def create_new_project(self, name, template): """Create a new project based on template""" import json from pathlib import Path # Create project ID from name project_id = name.lower().replace(" ", "_").replace("-", "_") # Template configurations templates = { "cube": { "config": { "launcher": { "name": name, "description": f"3D {name} project based on cube demo template", "enabled": True }, "window_width": 1024, "window_height": 768, "lua_script": f"scripts/{project_id}_logic.lua", "scripts_directory": "scripts", "mouse_grab": { "enabled": True, "grab_on_click": True, "release_on_escape": True, "start_grabbed": False, "hide_cursor": True, "relative_mode": True, "grab_mouse_button": "left", "release_key": "escape" }, "input_bindings": { "move_forward": "W", "move_back": "S", "move_left": "A", "move_right": "D", "fly_up": "Q", "fly_down": "Z", "jump": "Space", "noclip_toggle": "N", "music_toggle": "M" } }, "lua_script": f"""-- {name} Logic Script -- Generated from cube demo template local function init() -- Initialize your game here print("{name} initialized") end local function update(dt) -- Update game logic here -- dt is the time delta in seconds end local function render() -- Render your game here end local function shutdown() -- Cleanup resources here print("{name} shutdown") end -- Return the game interface return {{ init = init, update = update, render = render, shutdown = shutdown }} """ }, "gui": { "config": { "launcher": { "name": name, "description": f"GUI {name} project based on GUI demo template", "enabled": True }, "window_width": 1024, "window_height": 768, "lua_script": f"scripts/{project_id}_gui.lua", "scripts_directory": "scripts", "project_root": "../", "shaders_directory": "shaders", "device_extensions": ["VK_KHR_swapchain"], "mouse_grab": { "enabled": False } }, "lua_script": f"""-- {name} GUI Script -- Generated from GUI demo template local function init() -- Initialize ImGui interface here print("{name} GUI initialized") end local function update(dt) -- Update GUI logic here end local function render() -- Render ImGui interface here if imgui_begin then imgui_begin("{name}") imgui_text("Welcome to {name}!") if imgui_button then if imgui_button("Click me!") then print("Button clicked!") end end imgui_end() end end local function shutdown() -- Cleanup GUI resources here print("{name} GUI shutdown") end -- Return the GUI interface return {{ init = init, update = update, render = render, shutdown = shutdown }} """ }, "soundboard": { "config": { "launcher": { "name": name, "description": f"Soundboard {name} project based on soundboard template", "enabled": True }, "window_width": 1024, "window_height": 768, "lua_script": f"scripts/{project_id}_soundboard.lua", "scripts_directory": "scripts", "project_root": "../", "shaders_directory": "shaders", "device_extensions": ["VK_KHR_swapchain"], "config_file": f"config/{project_id}_runtime.json", "mouse_grab": { "enabled": False } }, "lua_script": f"""-- {name} Soundboard Script -- Generated from soundboard template local sounds = {{ -- Add your sound files here -- "sound1.ogg", -- "sound2.ogg" }} local function init() -- Initialize soundboard here print("{name} soundboard initialized") end local function update(dt) -- Update soundboard logic here end local function render() -- Render soundboard interface here if imgui_begin then imgui_begin("{name} Soundboard") imgui_text("Soundboard Controls") for i, sound in ipairs(sounds) do if imgui_button then if imgui_button("Play " .. sound) then if audio_play_sound then audio_play_sound(sound) end end end end imgui_end() end end local function shutdown() -- Cleanup soundboard resources here print("{name} soundboard shutdown") end -- Return the soundboard interface return {{ init = init, update = update, render = render, shutdown = shutdown }} """ } } try: # Create config file config_path = Path("config") / f"{project_id}_runtime.json" with open(config_path, 'w') as f: json.dump(templates[template]["config"], f, indent=2) # Create Lua script file script_path = Path("scripts") / f"{project_id}_{template}.lua" with open(script_path, 'w') as f: f.write(templates[template]["lua_script"]) self.log(f"Created new project '{name}' based on {template} template") self.log(f"Config: {config_path}") self.log(f"Script: {script_path}") # Refresh the games list self.games = self.load_games_from_config() self.game_list.clear() for game in self.games: item = QListWidgetItem(game["name"]) item.setData(Qt.ItemDataRole.UserRole, game) self.game_list.addItem(item) QMessageBox.information(self, "Project Created", f"New project '{name}' has been created successfully!\n\n" f"Config: {config_path}\n" f"Script: {script_path}") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to create project: {str(e)}") def on_game_selected(self, current, previous): """Handle game selection from library""" if current: game = current.data(Qt.ItemDataRole.UserRole) self.current_game = game self.game_title.setText(game["name"]) self.game_description.setText(game["description"]) self.play_btn.setEnabled(True) # Load the Lua script for this game self.load_lua_script() else: self.current_game = None self.game_title.setText("Select a game") self.game_description.setText("") self.play_btn.setEnabled(False) self.lua_editor.clear() self.lua_file_label.setText("No file loaded") self.save_lua_btn.setEnabled(False) self.reload_lua_btn.setEnabled(False) def load_lua_script(self): """Load the Lua script associated with the current game""" if not self.current_game: return import json try: # Read the config file to get the lua_script path config_file = self.current_game.get("config_file", "") with open(config_file, 'r') as f: config_data = json.load(f) lua_script_path = config_data.get("lua_script", "") if not lua_script_path: self.lua_editor.setPlainText("# No Lua script specified in config") self.lua_file_label.setText("No Lua script found") self.save_lua_btn.setEnabled(False) self.reload_lua_btn.setEnabled(False) return # Load the Lua script lua_file = Path(lua_script_path) if lua_file.exists(): with open(lua_file, 'r') as f: content = f.read() # Block signals to prevent marking as modified self.lua_editor.blockSignals(True) self.lua_editor.setPlainText(content) self.lua_editor.blockSignals(False) self.lua_file_label.setText(str(lua_file)) self.current_lua_file = lua_file self.lua_modified = False self.save_lua_btn.setEnabled(False) self.reload_lua_btn.setEnabled(True) else: self.lua_editor.setPlainText(f"# Lua script not found: {lua_script_path}") self.lua_file_label.setText(f"File not found: {lua_script_path}") self.save_lua_btn.setEnabled(False) self.reload_lua_btn.setEnabled(False) except Exception as e: self.lua_editor.setPlainText(f"# Error loading Lua script: {e}") self.lua_file_label.setText("Error loading script") self.save_lua_btn.setEnabled(False) self.reload_lua_btn.setEnabled(False) def on_lua_text_changed(self): """Handle Lua editor text changes""" if hasattr(self, 'current_lua_file'): self.lua_modified = True self.save_lua_btn.setEnabled(True) def save_lua_script(self): """Save the current Lua script""" if not hasattr(self, 'current_lua_file'): return try: with open(self.current_lua_file, 'w') as f: f.write(self.lua_editor.toPlainText()) self.lua_modified = False self.save_lua_btn.setEnabled(False) self.status_label.setText(f"āœ“ Saved {self.current_lua_file}") self.status_label.setStyleSheet("color: #4caf50; font-size: 9pt;") QTimer.singleShot(3000, lambda: self.status_label.setText("")) except Exception as e: self.status_label.setText(f"āŒ Error saving {self.current_lua_file}: {e}") self.status_label.setStyleSheet("color: #ff6b6b; font-size: 9pt;") QTimer.singleShot(5000, lambda: self.status_label.setText("")) def reload_lua_script(self): """Reload the Lua script from disk""" if self.lua_modified: # Warn user about unsaved changes from PyQt6.QtWidgets import QMessageBox reply = QMessageBox.question( self, 'Unsaved Changes', 'You have unsaved changes. Reload anyway?', QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.No: return self.load_lua_script() def search_in_editor(self, forward=True): """Search for text in the Lua editor""" text = self.search_edit.text() if text: flags = QTextDocument.FindFlag(0) if not forward: flags |= QTextDocument.FindFlag.FindBackward found = self.lua_editor.find(text, flags) if not found: # Wrap around cursor = self.lua_editor.textCursor() if forward: cursor.movePosition(QTextCursor.MoveOperation.Start) else: cursor.movePosition(QTextCursor.MoveOperation.End) self.lua_editor.setTextCursor(cursor) found = self.lua_editor.find(text, flags) if not found: self.status_label.setText(f"No matches for '{text}'") self.status_label.setStyleSheet("color: #ff6b6b; font-size: 9pt;") QTimer.singleShot(3000, lambda: self.status_label.setText("")) def copy_console(self): """Copy console output to clipboard""" from PyQt6.QtWidgets import QApplication clipboard = QApplication.clipboard() selected_text = self.console.textCursor().selectedText() if selected_text: clipboard.setText(selected_text) else: # If no selection, copy all text clipboard.setText(self.console.toPlainText()) def play_game(self): """Launch the selected game""" if not self.current_game: return build_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR) # Build the command to run the demo with the specific config file exe_name = "sdl3_app.exe" if IS_WINDOWS else "sdl3_app" binary = str(Path(build_dir) / exe_name) config_file = self.current_game.get("config_file", "config/seed_runtime.json") cmd = [binary, "-j", config_file] self.run_command(cmd) def stop_process(self): """Stop the running process""" if self.process and self.process.state() == QProcess.ProcessState.Running: self.log("\nāø Stopping process...") self.process.kill() self.process.waitForFinished(3000) def set_dark_theme(self): """Apply a dark theme similar to Steam""" palette = QPalette() palette.setColor(QPalette.ColorRole.Window, QColor(27, 40, 56)) palette.setColor(QPalette.ColorRole.WindowText, QColor(198, 209, 219)) palette.setColor(QPalette.ColorRole.Base, QColor(35, 47, 62)) palette.setColor(QPalette.ColorRole.AlternateBase, QColor(27, 40, 56)) palette.setColor(QPalette.ColorRole.Text, QColor(198, 209, 219)) palette.setColor(QPalette.ColorRole.Button, QColor(42, 71, 94)) palette.setColor(QPalette.ColorRole.ButtonText, QColor(198, 209, 219)) palette.setColor(QPalette.ColorRole.Highlight, QColor(91, 124, 153)) palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255)) self.setPalette(palette) def log(self, message: str): """Add a message to the console""" self.console.append(message) # Auto-scroll to bottom self.console.verticalScrollBar().setValue( self.console.verticalScrollBar().maximum() ) def run_command(self, args: list[str]): """Execute a command using QProcess""" if self.process and self.process.state() == QProcess.ProcessState.Running: self.log("āš ļø A process is already running. Stop it first.") return self.console.clear() self.log(f"ā–¶ Running: {' '.join(args)}\n") self.process = QProcess(self) self.process.readyReadStandardOutput.connect(self.handle_stdout) self.process.readyReadStandardError.connect(self.handle_stderr) self.process.finished.connect(self.process_finished) self.process.start(args[0], args[1:]) self.play_btn.setEnabled(False) self.stop_btn.setEnabled(True) def handle_stdout(self): """Handle stdout from the process""" if self.process: data = self.process.readAllStandardOutput() text = bytes(data).decode('utf-8', errors='replace') self.log(text) def handle_stderr(self): """Handle stderr from the process""" if self.process: data = self.process.readAllStandardError() text = bytes(data).decode('utf-8', errors='replace') self.log(text) def process_finished(self, exit_code: int, exit_status): """Handle process completion""" if exit_code == 0: self.log(f"\nāœ“ Process completed successfully") else: self.log(f"\nāŒ Process exited with code {exit_code}") self.stop_btn.setEnabled(False) if self.current_game: self.play_btn.setEnabled(True) def run_dependencies(self): """Run conan dependencies installation""" cmd = [sys.executable, __file__, "dependencies"] if self.preset == "vita-release": cmd.extend(["--conan-install-args", "--profile", "profiles/vita"]) self.run_command(cmd) def run_configure(self): """Run CMake configuration""" cmd = [sys.executable, __file__, "configure"] if self.preset != "default": cmd.extend(["--preset", self.preset]) else: cmd.extend([ "--generator", self.generator, "--build-type", self.build_type ]) self.run_command(cmd) def run_build(self): """Run build command""" if self.preset != "default": build_dir = f"build-{self.preset.split('-')[0]}" # e.g., build-vita else: build_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR) cmd = [ sys.executable, __file__, "build", "--build-dir", build_dir, "--target", self.target ] self.run_command(cmd) def sync_assets(self): """Sync assets into the active build directory""" if self.preset != "default": build_dir = f"build-{self.preset.split('-')[0]}" # e.g., build-vita else: build_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR) self.console.clear() self.log("=== Syncing Assets ===\n") _sync_assets(build_dir, dry_run=False) self.log("\nāœ“ Asset sync completed") app = QApplication(sys.argv) window = BuildLauncherGUI() window.show() sys.exit(app.exec()) def main() -> int: parser = argparse.ArgumentParser(description="Run build helper commands.") parser.add_argument( "--dry-run", action="store_true", help="print commands without executing them", ) subparsers = parser.add_subparsers(dest="command", required=True) deps = subparsers.add_parser("dependencies", help="run Conan setup from README") deps.add_argument( "--conan-install-args", nargs=argparse.REMAINDER, help=( "extra arguments forwarded to `conan install` " "(prefix with '--' before conan flags if needed)" ), ) deps.set_defaults(func=dependencies) conf = subparsers.add_parser("configure", help="configure CMake project") conf.add_argument( "--preset", help="use a CMake preset instead of manual configuration", ) conf.add_argument( "--generator", choices=["vs", "ninja", "ninja-msvc"], help=( "which generator to invoke (default: Ninja+MSVC on Windows, Ninja elsewhere)" ), ) conf.add_argument("--build-dir", help="override the directory where CMake writes build files") conf.add_argument( "--build-type", default="Release", help="single-config builds need an explicit CMAKE_BUILD_TYPE", ) conf.add_argument( "--cmake-args", nargs=argparse.REMAINDER, help=( "extra arguments forwarded to `cmake` configure step " "(prefix with '--' before cmake flags if needed)" ), ) conf.set_defaults(func=configure) bld = subparsers.add_parser("build", help="run cmake --build") bld.add_argument( "--build-dir", default=DEFAULT_BUILD_DIR, help="which directory to build" ) bld.add_argument( "--config", default="Release", help="configuration for multi-config generators" ) bld.add_argument( "--target", default="sdl3_app", help="target to build (e.g. sdl3_app, spinning_cube)", ) bld.add_argument( "--build-tool-args", nargs=argparse.REMAINDER, help=( "extra args forwarded to the underlying build tool after `--` " "(prefix with '--' before the tool args if needed)" ), ) bld.set_defaults(func=build) msvc = subparsers.add_parser( "msvc-quick", help="run a VS env setup + follow-on command (README one-liner style)" ) msvc.add_argument("--bat-path", help="full path to vcvarsall.bat") msvc.add_argument( "--arch", default="x64", help="architecture argument passed to vcvarsall.bat" ) msvc.add_argument( "--build-dir", default=DEFAULT_BUILD_DIR, help="build directory (used by default follow-on build command)", ) msvc.add_argument( "--config", default="Release", help="configuration for multi-config generators" ) msvc.add_argument( "--target", default="sdl3_app", help="target to build (used by default follow-on build)", ) msvc.add_argument( "--build-tool-args", nargs=argparse.REMAINDER, help=( "extra args forwarded to the underlying build tool after `--` " "when using the default follow-on build" ), ) msvc.add_argument( "then_command", nargs=argparse.REMAINDER, help=( "optional command to run after vcvarsall (overrides default build). " "Example: msvc-quick -- cmake -B build -S ." ), ) msvc.set_defaults(func=msvc_quick) runp = subparsers.add_parser( "run", help="execute a built binary from the build folder" ) runp.add_argument("--build-dir", help="where the binary lives") runp.add_argument( "--target", help="executable name to run (defaults to `sdl3_app[.exe]`)", ) runp.add_argument( "--no-sync", action="store_true", help="skip asset syncing before running", ) runp.add_argument( "args", nargs=argparse.REMAINDER, help=( "arguments forwarded to the executable " "(prefix with '--' before positional args when needed)" ), ) runp.set_defaults(func=run_demo) guip = subparsers.add_parser( "gui", help="launch PyQt6 GUI launcher (Steam-like interface)" ) guip.set_defaults(func=gui) args = parser.parse_args() try: args.func(args) except subprocess.CalledProcessError as exc: return int(exc.returncode) return 0 if __name__ == "__main__": raise SystemExit(main())