diff --git a/src/Commands/Update.php b/src/Commands/Update.php index 1947fb4..3254a36 100644 --- a/src/Commands/Update.php +++ b/src/Commands/Update.php @@ -5,8 +5,8 @@ use Sendama\Console\Util\Inspector; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; #[AsCommand( @@ -17,30 +17,56 @@ class Update extends Command { public function configure(): void { - $this->addOption('directory', 'd', InputArgument::OPTIONAL, 'The directory of the game', '.'); + $this->addOption('directory', ['d', 'dir'], InputOption::VALUE_REQUIRED, 'The directory of the game', '.'); } public function execute(InputInterface $input, OutputInterface $output): int { - write_console_info('Updating the game...'); + write_console_info('Updating the game...', $output); $directory = $input->getOption('directory') ?? '.'; $inspector = new Inspector($input, $output); $inspector->validateProjectDirectory($directory); - # Check if we are in a - $updateResult = exec('cd $directory && composer update --ansi'); + $exitCode = $this->runComposerUpdate((string) $directory); - if (false === $updateResult ) { - write_console_error('Update failed.'); + if ($exitCode !== 0) { + write_console_error('Update failed.', $output); return Command::FAILURE; } - $output->writeln($updateResult); + write_console_info('Update completed.', $output); return Command::SUCCESS; } + protected function runComposerUpdate(string $directory): int + { + $command = $this->buildComposerUpdateCommand($directory); + passthru($command, $exitCode); + + return $exitCode; + } + + protected function buildComposerUpdateCommand(string $directory): string + { + $composerPhar = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'composer.phar'; + + if (is_file($composerPhar)) { + return sprintf( + '%s %s update --working-dir=%s --ansi', + escapeshellarg(PHP_BINARY), + escapeshellarg($composerPhar), + escapeshellarg($directory), + ); + } + + return sprintf( + 'composer update --working-dir=%s --ansi', + escapeshellarg($directory), + ); + } + /** * Get a header. * @@ -52,4 +78,4 @@ private function getHeader(string $text, string $color = "\e[0;44m"): string { return sprintf("%s %s \e[0m\n", $color, $text); } -} \ No newline at end of file +} diff --git a/src/Commands/ViewLog.php b/src/Commands/ViewLog.php index 6b21c07..3307475 100644 --- a/src/Commands/ViewLog.php +++ b/src/Commands/ViewLog.php @@ -3,12 +3,15 @@ namespace Sendama\Console\Commands; use Sendama\Console\Enumerations\LogOption; +use Sendama\Console\Util\Inspector; use Sendama\Console\Util\Path; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; #[AsCommand( name: 'view:log', @@ -26,7 +29,7 @@ public function configure(): void LogOption::toArray() ); - $this->addOption('directory', 'd', InputArgument::OPTIONAL, 'The directory of the game', '.'); + $this->addOption('directory', ['d', 'dir'], InputOption::VALUE_REQUIRED, 'The directory of the game', '.'); } /** @@ -38,34 +41,123 @@ public function configure(): void */ public function execute(InputInterface $input, OutputInterface $output): int { - $type = $input->getArgument('type') ?? LogOption::ALL->value; - $directory = $input->getOption('directory') ?? '.'; + $typeValue = strtolower(trim((string) ($input->getArgument('type') ?? LogOption::ALL->value))); + $type = LogOption::tryFrom($typeValue); - if (! is_dir($directory) ) { - $output->writeln("Directory $directory not found."); + if (!$type instanceof LogOption) { + $output->writeln('Invalid log type. Use one of: ' . implode(', ', LogOption::toArray()) . '.'); return Command::FAILURE; } - $logFilename = Path::join($directory, 'logs', $type . '.log'); + $directory = $this->resolveAbsoluteDirectory((string) ($input->getOption('directory') ?? '.')); - $logFilename = str_replace('all.log', '*', $logFilename); + try { + $inspector = new Inspector($input, $output); + $inspector->validateProjectDirectory($directory); - if (! file_exists($logFilename) && $type !== LogOption::ALL->value) { - $output->writeln("Log file $logFilename not found."); + $logFiles = $this->resolveLogFiles($directory, $type); + + if ($logFiles === []) { + $output->writeln('No log files found.'); + return Command::FAILURE; + } + + $exitCode = $this->runLogViewer($logFiles, $type); + + if ($exitCode !== 0) { + $output->writeln('Failed to open log viewer.'); + return Command::FAILURE; + } + } catch (Throwable $exception) { + $output->writeln('' . $exception->getMessage() . ''); return Command::FAILURE; } - $logCommand = "tail "; + return Command::SUCCESS; + } + + protected function runLogViewer(array $logFiles, LogOption $type): int + { + $command = $this->buildLogViewerCommand($logFiles, $type); + passthru($command, $exitCode); + + return $exitCode; + } + + protected function buildLogViewerCommand(array $logFiles, LogOption $type): string + { + $escapedLogFiles = implode(' ', array_map( + static fn (string $logFile): string => escapeshellarg($logFile), + $logFiles, + )); - if (shell_exec("which multitail")) { - $logCommand = "multitail "; + if ($type === LogOption::ALL && count($logFiles) > 1 && $this->hasMultitail()) { + return 'multitail ' . $escapedLogFiles; } - if (false === shell_exec($logCommand . escapeshellarg($logFilename))) { - $output->writeln("Failed to open log file $logFilename."); - return Command::FAILURE; + $tailOptions = $type === LogOption::ALL && count($logFiles) > 1 + ? '-q -n 50 -f -- ' + : '-n 50 -f -- '; + + return 'tail ' . $tailOptions . $escapedLogFiles; + } + + protected function hasMultitail(): bool + { + $command = shell_exec('command -v multitail 2>/dev/null'); + + return is_string($command) && trim($command) !== ''; + } + + /** + * @return array + */ + private function resolveLogFiles(string $directory, LogOption $type): array + { + $logsDirectory = Path::join($directory, 'logs'); + + if (!is_dir($logsDirectory)) { + throw new \RuntimeException("Logs directory $logsDirectory not found."); } - return Command::SUCCESS; + if ($type === LogOption::ALL) { + $logFiles = array_values(array_filter([ + Path::join($logsDirectory, LogOption::DEBUG->value . '.log'), + Path::join($logsDirectory, LogOption::ERROR->value . '.log'), + ], 'is_file')); + + if ($logFiles === []) { + throw new \RuntimeException("No log files found in $logsDirectory."); + } + + return $logFiles; + } + + $logFile = Path::join($logsDirectory, $type->value . '.log'); + + if (!is_file($logFile)) { + throw new \RuntimeException("Log file $logFile not found."); + } + + return [$logFile]; + } + + private function resolveAbsoluteDirectory(string $directory): string + { + $normalizedDirectory = Path::normalize(trim($directory)); + + if ($normalizedDirectory === '' || $normalizedDirectory === '.') { + $normalizedDirectory = getcwd() ?: '.'; + } elseif (!str_starts_with($normalizedDirectory, '/')) { + $normalizedDirectory = Path::join(getcwd() ?: '.', $normalizedDirectory); + } + + $resolvedDirectory = realpath($normalizedDirectory); + + if (is_string($resolvedDirectory) && $resolvedDirectory !== '') { + return Path::normalize($resolvedDirectory); + } + + return Path::normalize($normalizedDirectory); } -} \ No newline at end of file +} diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php index e7e4bfb..a43fd6e 100644 --- a/src/Editor/Editor.php +++ b/src/Editor/Editor.php @@ -28,6 +28,8 @@ use Sendama\Console\Editor\States\PlayState; use Sendama\Console\Editor\States\ProjectBrowserState; use Sendama\Console\Editor\Widgets\AssetsPanel; +use Sendama\Console\Editor\Widgets\CommandHelpModal; +use Sendama\Console\Editor\Widgets\CommandLineModal; use Sendama\Console\Editor\Widgets\ConsolePanel; use Sendama\Console\Editor\Widgets\HierarchyPanel; use Sendama\Console\Editor\Widgets\InspectorPanel; @@ -148,6 +150,8 @@ final class Editor implements ObservableInterface protected int $terminalHeight = DEFAULT_TERMINAL_HEIGHT; protected PanelListModal $panelListModal; protected ?OptionListModal $projectNormalizationModal = null; + protected CommandLineModal $commandLineModal; + protected CommandHelpModal $commandHelpModal; protected bool $shouldRefreshBackgroundUnderModal = false; protected bool $didRenderOverlayLastFrame = false; protected SceneWriter $sceneWriter; @@ -460,7 +464,11 @@ private function update(): void $this->editorState->update(); $this->handlePanelKeyboardWorkflow(); - if ($this->panelListModal->isVisible()) { + if ( + $this->panelListModal->isVisible() + || $this->commandLineModal->isVisible() + || $this->commandHelpModal->isVisible() + ) { $this->notify(new EditorEvent(EventType::EDITOR_UPDATED->value, $this)); return; } @@ -542,6 +550,54 @@ private function render(): void return; } + if ($this->commandLineModal->isVisible()) { + $this->didRenderOverlayLastFrame = true; + $this->commandLineModal->syncLayout($this->terminalWidth, $this->terminalHeight); + + if ($this->shouldRefreshBackgroundUnderModal || $shouldRefreshForSnackbar) { + $this->renderEditorFrame(); + } + + if ($this->shouldRefreshBackgroundUnderModal || $this->commandLineModal->isDirty() || $shouldRefreshForSnackbar) { + $this->commandLineModal->render(); + + if ($hasActiveSnackbar) { + $this->snackbar->render(); + } + + $this->commandLineModal->markClean(); + $this->snackbar->markClean(); + $this->shouldRefreshBackgroundUnderModal = false; + } + + $this->notify(new EditorEvent(EventType::EDITOR_RENDERED->value, $this)); + return; + } + + if ($this->commandHelpModal->isVisible()) { + $this->didRenderOverlayLastFrame = true; + $this->commandHelpModal->syncLayout($this->terminalWidth, $this->terminalHeight); + + if ($this->shouldRefreshBackgroundUnderModal || $shouldRefreshForSnackbar) { + $this->renderEditorFrame(); + } + + if ($this->shouldRefreshBackgroundUnderModal || $this->commandHelpModal->isDirty() || $shouldRefreshForSnackbar) { + $this->commandHelpModal->render(); + + if ($hasActiveSnackbar) { + $this->snackbar->render(); + } + + $this->commandHelpModal->markClean(); + $this->snackbar->markClean(); + $this->shouldRefreshBackgroundUnderModal = false; + } + + $this->notify(new EditorEvent(EventType::EDITOR_RENDERED->value, $this)); + return; + } + if ($this->focusedPanel?->hasActiveModal()) { $this->didRenderOverlayLastFrame = true; $this->focusedPanel->syncModalLayout($this->terminalWidth, $this->terminalHeight); @@ -771,6 +827,8 @@ private function initializeWidgets(): void { $this->panels = new ItemList(Widget::class); $this->panelListModal = new PanelListModal(); + $this->commandLineModal = new CommandLineModal(); + $this->commandHelpModal = new CommandHelpModal(); $this->hierarchyPanel = new HierarchyPanel( sceneName: $this->loadedScene?->name ?? 'Scene', isSceneDirty: $this->loadedScene?->isDirty ?? false, @@ -831,6 +889,10 @@ private function handlePanelFocus(): void return; } + if ($this->commandLineModal->isVisible() || $this->commandHelpModal->isVisible()) { + return; + } + if ($this->focusedPanel?->hasActiveModal()) { $this->focusedPanel->handleModalMouseEvent($mouseEvent); return; @@ -966,6 +1028,16 @@ private function handlePanelKeyboardWorkflow(): void return; } + if ($this->commandHelpModal->isVisible()) { + $this->handleCommandHelpModalInput(); + return; + } + + if ($this->commandLineModal->isVisible()) { + $this->handleCommandLineModalInput(); + return; + } + if ($this->panelListModal->isVisible()) { $this->handlePanelListModalInput(); return; @@ -980,6 +1052,11 @@ private function handlePanelKeyboardWorkflow(): void return; } + if (Input::getCurrentInput() === ':') { + $this->showCommandLineModal(); + return; + } + if (Input::isKeyDown(IO\Enumerations\KeyCode::SHIFT_UP)) { $this->focusSiblingPanel('top'); return; @@ -1037,6 +1114,8 @@ private function refreshTerminalSize(bool $force = false): void if ( $this->projectNormalizationModal?->isVisible() || $this->panelListModal->isVisible() + || $this->commandLineModal->isVisible() + || $this->commandHelpModal->isVisible() || $this->focusedPanel?->hasActiveModal() ) { $this->shouldRefreshBackgroundUnderModal = true; @@ -1073,6 +1152,8 @@ private function layoutPanels(): void $this->panelListModal->syncLayout($this->terminalWidth, $this->terminalHeight); $this->projectNormalizationModal?->syncLayout($this->terminalWidth, $this->terminalHeight); + $this->commandLineModal->syncLayout($this->terminalWidth, $this->terminalHeight); + $this->commandHelpModal->syncLayout($this->terminalWidth, $this->terminalHeight); $this->focusedPanel?->syncModalLayout($this->terminalWidth, $this->terminalHeight); $this->snackbar->syncLayout($this->terminalWidth, $this->terminalHeight); } @@ -1259,6 +1340,122 @@ private function handlePanelListModalInput(): void } } + private function showCommandLineModal(): void + { + $this->commandLineModal->show(); + $this->commandLineModal->syncLayout($this->terminalWidth, $this->terminalHeight); + $this->shouldRefreshBackgroundUnderModal = true; + } + + private function hideCommandLineModal(): void + { + $this->commandLineModal->hide(); + $this->shouldRefreshBackgroundUnderModal = true; + } + + private function handleCommandLineModalInput(): void + { + if (Input::isKeyDown(IO\Enumerations\KeyCode::ESCAPE)) { + $this->hideCommandLineModal(); + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::ENTER)) { + $command = $this->commandLineModal->submit(); + $this->hideCommandLineModal(); + $this->executeEditorCommand($command); + return; + } + + if (Input::isKeyPressed(IO\Enumerations\KeyCode::BACKSPACE)) { + $this->commandLineModal->deleteBackward(); + return; + } + + if (Input::isKeyPressed(IO\Enumerations\KeyCode::LEFT)) { + $this->commandLineModal->moveCursorLeft(); + return; + } + + if (Input::isKeyPressed(IO\Enumerations\KeyCode::RIGHT)) { + $this->commandLineModal->moveCursorRight(); + return; + } + + $this->commandLineModal->handleInput(Input::getCurrentInput()); + } + + private function executeEditorCommand(string $command): void + { + $normalizedCommand = strtolower(trim($command)); + + if ($normalizedCommand === '') { + return; + } + + if (in_array($normalizedCommand, ['help', 'h'], true)) { + $this->showCommandHelpModal(); + return; + } + + $this->pushNotification(sprintf('Unknown command: %s', $command), 'error'); + } + + private function showCommandHelpModal(): void + { + $this->commandHelpModal->show($this->buildEditorCheatsheetLines()); + $this->commandHelpModal->syncLayout($this->terminalWidth, $this->terminalHeight); + $this->shouldRefreshBackgroundUnderModal = true; + } + + private function handleCommandHelpModalInput(): void + { + if ( + Input::isKeyDown(IO\Enumerations\KeyCode::ESCAPE) + || Input::isKeyDown(IO\Enumerations\KeyCode::ENTER) + ) { + $this->commandHelpModal->hide(); + $this->shouldRefreshBackgroundUnderModal = true; + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::UP)) { + $this->commandHelpModal->scroll(-1); + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::DOWN)) { + $this->commandHelpModal->scroll(1); + } + } + + private function buildEditorCheatsheetLines(): array + { + return [ + 'Type :help to open this cheatsheet.', + '', + 'Global', + ' Ctrl+S save the current scene', + ' Shift+5 start or stop play mode', + ' ! open the panel switcher', + ' : open the command line', + '', + 'Navigation', + ' Shift+Arrows move focus between panels', + ' Tab / Shift+Tab cycle within the focused panel', + ' Esc closes the active modal or edit mode', + '', + 'Inspector', + ' Enter edit or choose the selected property', + ' Shift+A add a component', + ' Shift+W reorder components when available', + '', + 'Assets and Scene', + ' Enter opens the selected asset or inspects the selected object', + ' Arrow keys move through lists and scene selections', + ]; + } + private function synchronizeInspectorPanel(): void { $selectedItem = $this->hierarchyPanel->consumeInspectionRequest() diff --git a/src/Editor/EditorColorScheme.php b/src/Editor/EditorColorScheme.php new file mode 100644 index 0000000..56237cc --- /dev/null +++ b/src/Editor/EditorColorScheme.php @@ -0,0 +1,34 @@ +getTexture() + : (property_exists($value, 'texture') ? $value->texture : null); + $rect = method_exists($value, 'getRect') + ? $value->getRect() + : (property_exists($value, 'rect') ? $value->rect : null); + $pivot = method_exists($value, 'getPivot') + ? $value->getPivot() + : (property_exists($value, 'pivot') ? $value->pivot : null); + + if ($texture !== null) { + $normalizedSprite['texture'] = normalize_editor_value($texture); + } + + if ($rect !== null) { + $normalizedSprite['rect'] = normalize_editor_value($rect); + } + + if ($pivot !== null) { + $normalizedSprite['pivot'] = normalize_editor_value($pivot); + } + + if ($normalizedSprite !== []) { + return $normalizedSprite; + } + } + + if (is_a($value, '\Sendama\Engine\Core\Texture')) { + $path = method_exists($value, 'getPath') + ? $value->getPath() + : (property_exists($value, 'path') ? $value->path : null); + $path = is_string($path) ? trim($path) : ''; + + if ($path !== '') { + $normalizedTexture = ['path' => $path]; + $requestedWidth = method_exists($value, 'getRequestedWidth') ? $value->getRequestedWidth() : null; + $requestedHeight = method_exists($value, 'getRequestedHeight') ? $value->getRequestedHeight() : null; + $color = method_exists($value, 'getColor') ? $value->getColor() : null; + + if (is_int($requestedWidth) && $requestedWidth > 0) { + $normalizedTexture['width'] = $requestedWidth; + } + + if (is_int($requestedHeight) && $requestedHeight > 0) { + $normalizedTexture['height'] = $requestedHeight; + } + + if ($color !== null) { + $normalizedTexture['color'] = normalize_editor_value($color); + } + + return count($normalizedTexture) === 1 + ? $normalizedTexture['path'] + : $normalizedTexture; + } + } + + if ( + (is_a($value, '\Sendama\Engine\Core\Rect') + || (method_exists($value, 'getWidth') && method_exists($value, 'getHeight'))) + && method_exists($value, 'getX') + && method_exists($value, 'getY') + && method_exists($value, 'getWidth') + && method_exists($value, 'getHeight') + ) { + return [ + 'x' => normalize_editor_value($value->getX()), + 'y' => normalize_editor_value($value->getY()), + 'width' => normalize_editor_value($value->getWidth()), + 'height' => normalize_editor_value($value->getHeight()), + ]; + } + if (method_exists($value, 'getX') && method_exists($value, 'getY')) { return [ 'x' => normalize_editor_value($value->getX()), @@ -107,6 +182,12 @@ function normalize_editor_value(mixed $value): mixed } } + $compoundValue = extract_compound_editor_value($value); + + if (is_array($compoundValue)) { + return $compoundValue; + } + if ($value instanceof Stringable) { return (string) $value; } @@ -114,6 +195,45 @@ function normalize_editor_value(mixed $value): mixed return get_class($value); } +function extract_compound_editor_value(object $value): ?array +{ + $valueClass = $value::class; + + if ( + is_a($valueClass, '\Sendama\Engine\Core\Component', true) + || is_a($valueClass, '\Sendama\Engine\Core\GameObject', true) + || is_a($valueClass, '\Sendama\Engine\UI\UIElement', true) + ) { + return null; + } + + try { + $reflection = new \ReflectionObject($value); + } catch (Throwable) { + return null; + } + + $normalized = []; + + foreach ($reflection->getProperties() as $property) { + if ( + $property->isStatic() + || (!$property->isPublic() && $property->getAttributes('Sendama\Engine\Core\Behaviours\Attributes\SerializeField') === []) + || (method_exists($property, 'isVirtual') && $property->isVirtual()) + ) { + continue; + } + + try { + $normalized[$property->getName()] = normalize_editor_value($property->getValue($value)); + } catch (Throwable) { + continue; + } + } + + return $normalized !== [] ? $normalized : null; +} + function build_vector(mixed $value, array $default = ['x' => 0, 'y' => 0]): ?object { if (!class_exists('\Sendama\Engine\Core\Vector2')) { @@ -472,7 +592,7 @@ function enrich_prefab_item(mixed $item): mixed try { if ($autoloadPath !== '' && is_file($autoloadPath)) { - require $autoloadPath; + @require $autoloadPath; } $prefabData = require $prefabPath; diff --git a/src/Editor/ProjectAutoloadLoader.php b/src/Editor/ProjectAutoloadLoader.php new file mode 100644 index 0000000..b49203b --- /dev/null +++ b/src/Editor/ProjectAutoloadLoader.php @@ -0,0 +1,58 @@ +getTexture() + : (property_exists($value, 'texture') ? $value->texture : null); + $rect = method_exists($value, 'getRect') + ? $value->getRect() + : (property_exists($value, 'rect') ? $value->rect : null); + $pivot = method_exists($value, 'getPivot') + ? $value->getPivot() + : (property_exists($value, 'pivot') ? $value->pivot : null); + + if ($texture !== null) { + $normalizedSprite['texture'] = normalize_editor_value($texture); + } + + if ($rect !== null) { + $normalizedSprite['rect'] = normalize_editor_value($rect); + } + + if ($pivot !== null) { + $normalizedSprite['pivot'] = normalize_editor_value($pivot); + } + + if ($normalizedSprite !== []) { + return $normalizedSprite; + } + } + + if (is_a($value, '\Sendama\Engine\Core\Texture')) { + $path = method_exists($value, 'getPath') + ? $value->getPath() + : (property_exists($value, 'path') ? $value->path : null); + $path = is_string($path) ? trim($path) : ''; + + if ($path !== '') { + $normalizedTexture = ['path' => $path]; + $requestedWidth = method_exists($value, 'getRequestedWidth') ? $value->getRequestedWidth() : null; + $requestedHeight = method_exists($value, 'getRequestedHeight') ? $value->getRequestedHeight() : null; + $color = method_exists($value, 'getColor') ? $value->getColor() : null; + + if (is_int($requestedWidth) && $requestedWidth > 0) { + $normalizedTexture['width'] = $requestedWidth; + } + + if (is_int($requestedHeight) && $requestedHeight > 0) { + $normalizedTexture['height'] = $requestedHeight; + } + + if ($color !== null) { + $normalizedTexture['color'] = normalize_editor_value($color); + } + + return count($normalizedTexture) === 1 + ? $normalizedTexture['path'] + : $normalizedTexture; + } + } + + if ( + (is_a($value, '\Sendama\Engine\Core\Rect') + || (method_exists($value, 'getWidth') && method_exists($value, 'getHeight'))) + && method_exists($value, 'getX') + && method_exists($value, 'getY') + && method_exists($value, 'getWidth') + && method_exists($value, 'getHeight') + ) { + return [ + 'x' => normalize_editor_value($value->getX()), + 'y' => normalize_editor_value($value->getY()), + 'width' => normalize_editor_value($value->getWidth()), + 'height' => normalize_editor_value($value->getHeight()), + ]; + } + if (method_exists($value, 'getX') && method_exists($value, 'getY')) { return [ 'x' => normalize_editor_value($value->getX()), @@ -283,6 +358,12 @@ function normalize_editor_value(mixed $value): mixed } } + $compoundValue = extract_compound_editor_value($value); + + if (is_array($compoundValue)) { + return $compoundValue; + } + if ($value instanceof Stringable) { return (string) $value; } @@ -290,6 +371,45 @@ function normalize_editor_value(mixed $value): mixed return get_class($value); } +function extract_compound_editor_value(object $value): ?array +{ + $valueClass = $value::class; + + if ( + is_a($valueClass, '\Sendama\Engine\Core\Component', true) + || is_a($valueClass, '\Sendama\Engine\Core\GameObject', true) + || is_a($valueClass, '\Sendama\Engine\UI\UIElement', true) + ) { + return null; + } + + try { + $reflection = new \ReflectionObject($value); + } catch (Throwable) { + return null; + } + + $normalized = []; + + foreach ($reflection->getProperties() as $property) { + if ( + $property->isStatic() + || (!$property->isPublic() && $property->getAttributes('Sendama\Engine\Core\Behaviours\Attributes\SerializeField') === []) + || (method_exists($property, 'isVirtual') && $property->isVirtual()) + ) { + continue; + } + + try { + $normalized[$property->getName()] = normalize_editor_value($property->getValue($value)); + } catch (Throwable) { + continue; + } + } + + return $normalized !== [] ? $normalized : null; +} + function build_vector(mixed $value, array $default = ['x' => 0, 'y' => 0]): ?object { if (!class_exists('\Sendama\Engine\Core\Vector2')) { @@ -686,7 +806,7 @@ function enrich_scene_data(array $sceneData): array try { if ($autoloadPath !== '' && is_file($autoloadPath)) { - require $autoloadPath; + @require $autoloadPath; } $sceneData = require $scenePath; diff --git a/src/Editor/Widgets/AssetsPanel.php b/src/Editor/Widgets/AssetsPanel.php index d1dc599..2a9fc98 100644 --- a/src/Editor/Widgets/AssetsPanel.php +++ b/src/Editor/Widgets/AssetsPanel.php @@ -5,6 +5,7 @@ use Atatusoft\Termutil\Events\MouseEvent; use Atatusoft\Termutil\IO\Enumerations\Color; use Sendama\Console\Debug\Debug; +use Sendama\Console\Editor\EditorColorScheme; use Sendama\Console\Editor\IO\Enumerations\KeyCode; use Sendama\Console\Editor\IO\Input; use Sendama\Console\Util\Path; @@ -22,8 +23,8 @@ class AssetsPanel extends Widget private const string COLLAPSED_ICON = '►'; private const string EXPANDED_ICON = '▼'; private const string LEAF_ICON = '•'; - private const string SELECTED_ROW_SEQUENCE = "\033[30;46m"; - private const string SELECTED_ROW_FOCUSED_SEQUENCE = "\033[5;30;46m"; + private const string SELECTED_ROW_SEQUENCE = EditorColorScheme::SELECTED_ROW_SEQUENCE; + private const string SELECTED_ROW_FOCUSED_SEQUENCE = EditorColorScheme::SELECTED_ROW_FOCUSED_SEQUENCE; protected array $assetTree = []; protected array $visibleAssets = []; diff --git a/src/Editor/Widgets/CommandHelpModal.php b/src/Editor/Widgets/CommandHelpModal.php new file mode 100644 index 0000000..05e996c --- /dev/null +++ b/src/Editor/Widgets/CommandHelpModal.php @@ -0,0 +1,104 @@ + 1, 'y' => 1], + width: 56, + height: 14, + ); + } + + public function show(array $lines): void + { + $this->content = array_values(array_map( + static fn(mixed $line): string => (string) $line, + $lines, + )); + $this->verticalScrollOffset = 0; + $this->isVisible = true; + $this->markDirty(); + } + + public function hide(): void + { + if (!$this->isVisible) { + return; + } + + $this->isVisible = false; + $this->markDirty(); + } + + public function isVisible(): bool + { + return $this->isVisible; + } + + public function isDirty(): bool + { + return $this->isDirty; + } + + public function markClean(): void + { + $this->isDirty = false; + } + + public function scroll(int $offset): void + { + $this->setScrollbarOffset($this->verticalScrollOffset + $offset); + $this->markDirty(); + } + + public function syncLayout(int $terminalWidth, int $terminalHeight): void + { + $longestLineLength = 0; + + foreach ($this->content as $line) { + $longestLineLength = max($longestLineLength, mb_strlen($line)); + } + + $desiredWidth = max( + 42, + min($terminalWidth - 2, $longestLineLength + 6), + mb_strlen($this->title) + 4, + mb_strlen($this->help) + 4, + ); + $desiredHeight = max(8, min(max(10, count($this->content) + 2), $terminalHeight - 2)); + $modalWidth = min($desiredWidth, max(3, $terminalWidth - 2)); + $modalHeight = min($desiredHeight, max(3, $terminalHeight - 2)); + $modalX = max(1, intdiv($terminalWidth - $modalWidth, 2) + 1); + $modalY = max(1, intdiv($terminalHeight - $modalHeight, 2) + 1); + $layoutChanged = + $this->width !== $modalWidth + || $this->height !== $modalHeight + || $this->x !== $modalX + || $this->y !== $modalY; + + $this->setDimensions($modalWidth, $modalHeight); + $this->setPosition($modalX, $modalY); + + if ($layoutChanged) { + $this->markDirty(); + } + } + + public function update(): void + { + } + + private function markDirty(): void + { + $this->isDirty = true; + } +} diff --git a/src/Editor/Widgets/CommandLineModal.php b/src/Editor/Widgets/CommandLineModal.php new file mode 100644 index 0000000..857c1bf --- /dev/null +++ b/src/Editor/Widgets/CommandLineModal.php @@ -0,0 +1,181 @@ + 1, 'y' => 1], + width: 36, + height: 3, + ); + } + + public function show(string $initialValue = ''): void + { + $this->input = $initialValue; + $this->cursorPosition = mb_strlen($initialValue); + $this->isVisible = true; + $this->refreshContent(); + $this->markDirty(); + } + + public function hide(): void + { + if (!$this->isVisible) { + return; + } + + $this->isVisible = false; + $this->markDirty(); + } + + public function isVisible(): bool + { + return $this->isVisible; + } + + public function isDirty(): bool + { + return $this->isDirty; + } + + public function markClean(): void + { + $this->isDirty = false; + } + + public function getInput(): string + { + return $this->input; + } + + public function submit(): string + { + return trim($this->input); + } + + public function handleInput(string $input): bool + { + if (!$this->isVisible || !$this->isPrintableInput($input)) { + return false; + } + + $beforeCursor = mb_substr($this->input, 0, $this->cursorPosition); + $afterCursor = mb_substr($this->input, $this->cursorPosition); + + $this->input = $beforeCursor . $input . $afterCursor; + $this->cursorPosition++; + $this->refreshContent(); + $this->markDirty(); + + return true; + } + + public function deleteBackward(): bool + { + if (!$this->isVisible || $this->cursorPosition <= 0) { + return false; + } + + $beforeCursor = mb_substr($this->input, 0, $this->cursorPosition - 1); + $afterCursor = mb_substr($this->input, $this->cursorPosition); + + $this->input = $beforeCursor . $afterCursor; + $this->cursorPosition--; + $this->refreshContent(); + $this->markDirty(); + + return true; + } + + public function moveCursorLeft(): bool + { + if (!$this->isVisible || $this->cursorPosition <= 0) { + return false; + } + + $this->cursorPosition--; + $this->refreshContent(); + $this->markDirty(); + + return true; + } + + public function moveCursorRight(): bool + { + if (!$this->isVisible || $this->cursorPosition >= mb_strlen($this->input)) { + return false; + } + + $this->cursorPosition++; + $this->refreshContent(); + $this->markDirty(); + + return true; + } + + public function syncLayout(int $terminalWidth, int $terminalHeight): void + { + $desiredWidth = max(28, min($terminalWidth - 2, 52)); + $modalWidth = min($desiredWidth, max(3, $terminalWidth - 2)); + $modalX = max(1, intdiv($terminalWidth - $modalWidth, 2) + 1); + $modalY = max(1, $terminalHeight - 3); + $layoutChanged = + $this->width !== $modalWidth + || $this->height !== 3 + || $this->x !== $modalX + || $this->y !== $modalY; + + $this->setDimensions($modalWidth, 3); + $this->setPosition($modalX, $modalY); + $this->refreshContent(); + + if ($layoutChanged) { + $this->markDirty(); + } + } + + public function update(): void + { + } + + protected function usesAutomaticVerticalScrolling(): bool + { + return false; + } + + private function refreshContent(): void + { + $beforeCursor = mb_substr($this->input, 0, $this->cursorPosition); + $atCursor = mb_substr($this->input, $this->cursorPosition, 1); + $afterCursor = mb_substr($this->input, $this->cursorPosition + ($atCursor === '' ? 0 : 1)); + + $renderedInput = $atCursor === '' + ? $beforeCursor . '|' + : $beforeCursor . '|' . $atCursor . $afterCursor; + + $this->content = [':' . $renderedInput]; + } + + private function markDirty(): void + { + $this->isDirty = true; + } + + private function isPrintableInput(string $input): bool + { + return $input !== '' + && mb_strlen($input) === 1 + && !(function_exists('ctype_cntrl') && ctype_cntrl($input)); + } +} diff --git a/src/Editor/Widgets/ConsolePanel.php b/src/Editor/Widgets/ConsolePanel.php index 153fd63..e3bbec9 100644 --- a/src/Editor/Widgets/ConsolePanel.php +++ b/src/Editor/Widgets/ConsolePanel.php @@ -4,6 +4,7 @@ use Atatusoft\Termutil\Events\MouseEvent; use Atatusoft\Termutil\IO\Enumerations\Color; +use Sendama\Console\Editor\EditorColorScheme; use Sendama\Console\Editor\IO\Enumerations\KeyCode; use Sendama\Console\Editor\IO\Input; @@ -39,7 +40,7 @@ class ConsolePanel extends Widget protected int $activeTabIndex = 0; protected int $activeTabOffset = 0; protected int $activeTabLength = 0; - protected Color $activeIndicatorColor = Color::LIGHT_CYAN; + protected Color $activeIndicatorColor = EditorColorScheme::ACTIVE_INDICATOR_COLOR; protected array $activeFiltersByTab = [ 'Debug' => 'DEBUG', 'Error' => 'ALL', @@ -516,11 +517,11 @@ private function colorizeLogTag(string $content): string private function resolveLogLevelColor(string $level): ?Color { return match ($level) { - 'ERROR' => Color::LIGHT_RED, - 'CRITICAL', 'FATAL' => Color::RED, - 'INFO' => Color::LIGHT_BLUE, - 'WARN', 'WARNING' => Color::YELLOW, - 'DEBUG' => Color::LIGHT_GRAY, + 'ERROR' => EditorColorScheme::ERROR_COLOR, + 'CRITICAL', 'FATAL' => EditorColorScheme::FATAL_COLOR, + 'INFO' => EditorColorScheme::INFO_COLOR, + 'WARN', 'WARNING' => EditorColorScheme::WARNING_COLOR, + 'DEBUG' => EditorColorScheme::DEBUG_COLOR, default => null, }; } diff --git a/src/Editor/Widgets/Controls/InputControl.php b/src/Editor/Widgets/Controls/InputControl.php index 34b1c63..746261e 100644 --- a/src/Editor/Widgets/Controls/InputControl.php +++ b/src/Editor/Widgets/Controls/InputControl.php @@ -6,6 +6,7 @@ abstract class InputControl { protected bool $hasFocus = false; protected bool $isEditing = false; + protected ?int $availableWidth = null; public function __construct( protected string $label, @@ -118,6 +119,13 @@ public function update(): void { } + public function setAvailableWidth(?int $availableWidth): void + { + $this->availableWidth = $availableWidth !== null + ? max(0, $availableWidth) + : null; + } + abstract public function renderLines(): array; public function renderLineDefinitions(): array @@ -135,6 +143,16 @@ protected function indentation(int $offset = 0): string return str_repeat(' ', max(0, $this->indentLevel + $offset)); } + protected function getAvailableWidth(): ?int + { + return $this->availableWidth; + } + + protected function getDisplayWidth(string $content): int + { + return mb_strwidth($content, 'UTF-8'); + } + protected function formatScalarValue(mixed $value): string { return match (true) { diff --git a/src/Editor/Widgets/Controls/SliderInputControl.php b/src/Editor/Widgets/Controls/SliderInputControl.php new file mode 100644 index 0000000..0dfc7fc --- /dev/null +++ b/src/Editor/Widgets/Controls/SliderInputControl.php @@ -0,0 +1,242 @@ + $resolvedMaximum) { + [$resolvedMinimum, $resolvedMaximum] = [$resolvedMaximum, $resolvedMinimum]; + } + + $resolvedStep = abs((float) $step); + $this->prefersFloat = $this->shouldPreferFloat($value, $minimum, $maximum, $step); + + $this->minimum = $this->normalizeCommittedValue($resolvedMinimum); + $this->maximum = $this->normalizeCommittedValue($resolvedMaximum); + $this->step = $resolvedStep > 0 + ? $this->normalizeCommittedValue($resolvedStep) + : $this->normalizeCommittedValue(1); + $this->value = $this->clampCommittedValue($value); + $this->editingValue = $this->value; + } + + public function setValue(mixed $value): void + { + $this->value = $this->clampCommittedValue($value); + $this->editingValue = $this->value; + } + + public function enterEditMode(): bool + { + if (!parent::enterEditMode()) { + return false; + } + + $this->editingValue = $this->value; + + return true; + } + + public function commitEdit(): bool + { + if ($this->isEditing) { + $this->value = $this->clampCommittedValue($this->editingValue); + } + + return parent::commitEdit(); + } + + public function cancelEdit(): void + { + $this->editingValue = $this->value; + parent::cancelEdit(); + } + + public function increment(): bool + { + if (!$this->isEditing) { + return false; + } + + $this->editingValue = $this->clampCommittedValue( + $this->toNumeric($this->editingValue) + $this->toNumeric($this->step), + ); + + return true; + } + + public function decrement(): bool + { + if (!$this->isEditing) { + return false; + } + + $this->editingValue = $this->clampCommittedValue( + $this->toNumeric($this->editingValue) - $this->toNumeric($this->step), + ); + + return true; + } + + public function moveCursorLeft(): bool + { + return $this->decrement(); + } + + public function moveCursorRight(): bool + { + return $this->increment(); + } + + public function renderLines(): array + { + $currentValue = $this->isEditing ? $this->editingValue : $this->value; + $prefix = sprintf('%s%s: ', $this->indentation(), $this->label); + $valueLabel = $this->formatCommittedValue($currentValue); + $availableWidth = $this->getAvailableWidth(); + + if ($availableWidth !== null) { + $singleLineFixedWidth = $this->getDisplayWidth($prefix) + $this->getDisplayWidth($valueLabel) + 3; + $singleLineTrackLength = min( + self::DEFAULT_TRACK_LENGTH, + max(0, $availableWidth - $singleLineFixedWidth), + ); + + if ($singleLineTrackLength >= self::MINIMUM_TRACK_LENGTH) { + return [ + sprintf( + '%s[%s] %s', + $prefix, + $this->buildTrack($currentValue, $singleLineTrackLength), + $valueLabel, + ), + ]; + } + + $trackPrefix = $this->indentation(1); + $trackWidth = min( + self::DEFAULT_TRACK_LENGTH, + max( + self::MINIMUM_TRACK_LENGTH, + $availableWidth - $this->getDisplayWidth($trackPrefix) - 2, + ), + ); + + return [ + $prefix . $valueLabel, + sprintf('%s[%s]', $trackPrefix, $this->buildTrack($currentValue, $trackWidth)), + ]; + } + + return [ + sprintf( + '%s%s: [%s] %s', + $this->indentation(), + $this->label, + $this->buildTrack($currentValue, self::DEFAULT_TRACK_LENGTH), + $valueLabel, + ), + ]; + } + + private function buildTrack(mixed $currentValue, int $trackLength): string + { + $trackLength = max(1, $trackLength); + $minimum = $this->toNumeric($this->minimum); + $maximum = $this->toNumeric($this->maximum); + $range = max(0.0, $maximum - $minimum); + $normalizedValue = $this->toNumeric($currentValue); + $ratio = $range <= 0 + ? 1.0 + : (($normalizedValue - $minimum) / $range); + $ratio = max(0.0, min(1.0, $ratio)); + $filledSegments = (int) round($ratio * $trackLength); + + return str_repeat('#', $filledSegments) . str_repeat('-', $trackLength - $filledSegments); + } + + private function clampCommittedValue(mixed $value): int|float + { + $numericValue = $this->toNumeric($value); + $minimum = $this->toNumeric($this->minimum); + $maximum = $this->toNumeric($this->maximum); + + if ($numericValue < $minimum) { + $numericValue = $minimum; + } + + if ($numericValue > $maximum) { + $numericValue = $maximum; + } + + return $this->normalizeCommittedValue($numericValue); + } + + private function normalizeCommittedValue(mixed $value): int|float + { + $numericValue = $this->toNumeric($value); + + if ($this->prefersFloat) { + return (float) $numericValue; + } + + return (int) round($numericValue); + } + + private function formatCommittedValue(mixed $value): string + { + $numericValue = $this->normalizeCommittedValue($value); + + if (!$this->prefersFloat) { + return (string) $numericValue; + } + + $formatted = rtrim(rtrim(number_format((float) $numericValue, 6, '.', ''), '0'), '.'); + + return $formatted === '' ? '0' : $formatted; + } + + private function toNumeric(mixed $value): float + { + return is_numeric($value) ? (float) $value : 0.0; + } + + private function shouldPreferFloat(mixed ...$candidates): bool + { + foreach ($candidates as $candidate) { + if (is_float($candidate)) { + return true; + } + + if (is_string($candidate) && is_numeric($candidate) && str_contains($candidate, '.')) { + return true; + } + } + + return false; + } +} diff --git a/src/Editor/Widgets/Controls/UIElementReferenceInputControl.php b/src/Editor/Widgets/Controls/UIElementReferenceInputControl.php new file mode 100644 index 0000000..9475788 --- /dev/null +++ b/src/Editor/Widgets/Controls/UIElementReferenceInputControl.php @@ -0,0 +1,51 @@ +normalizeValue($value), $indentLevel, $isReadOnly); + } + + public function setValue(mixed $value): void + { + $this->value = $this->normalizeValue($value); + } + + public function renderLines(): array + { + return [ + $this->indentation() . $this->label . ': ' . $this->resolveDisplayValue(), + ]; + } + + private function normalizeValue(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + + $normalizedValue = trim($value); + + return $normalizedValue !== '' ? $normalizedValue : null; + } + + private function resolveDisplayValue(): string + { + $value = $this->value; + + if (!is_string($value) || $value === '') { + return 'None'; + } + + return $this->displayLabelsByName[$value] ?? $value; + } +} diff --git a/src/Editor/Widgets/FileDialogModal.php b/src/Editor/Widgets/FileDialogModal.php index b84a33b..6bbc31e 100644 --- a/src/Editor/Widgets/FileDialogModal.php +++ b/src/Editor/Widgets/FileDialogModal.php @@ -3,6 +3,7 @@ namespace Sendama\Console\Editor\Widgets; use Atatusoft\Termutil\IO\Enumerations\Color; +use Sendama\Console\Editor\EditorColorScheme; use Sendama\Console\Util\Path; class FileDialogModal extends Widget @@ -11,7 +12,7 @@ class FileDialogModal extends Widget private const string COLLAPSED_ICON = '►'; private const string EXPANDED_ICON = '▼'; private const string LEAF_ICON = '•'; - private const string SELECTED_ROW_SEQUENCE = "\033[30;46m"; + private const string SELECTED_ROW_SEQUENCE = EditorColorScheme::SELECTED_ROW_SEQUENCE; protected bool $isVisible = false; protected bool $isDirty = false; diff --git a/src/Editor/Widgets/HierarchyPanel.php b/src/Editor/Widgets/HierarchyPanel.php index 99bcb27..57a6c4d 100644 --- a/src/Editor/Widgets/HierarchyPanel.php +++ b/src/Editor/Widgets/HierarchyPanel.php @@ -6,6 +6,7 @@ use Atatusoft\Termutil\Events\Interfaces\ObservableInterface; use Atatusoft\Termutil\Events\Traits\ObservableTrait; use Atatusoft\Termutil\IO\Enumerations\Color; +use Sendama\Console\Editor\EditorColorScheme; use Sendama\Console\Editor\FocusTargetContext; use Sendama\Console\Editor\Events\EditorEvent; use Sendama\Console\Editor\Events\Enumerations\EventType; @@ -32,10 +33,10 @@ class HierarchyPanel extends Widget implements ObservableInterface private const string COLLAPSED_ICON = '►'; private const string EXPANDED_ICON = '▼'; private const string LEAF_ICON = '•'; - private const string SELECTED_ROW_SEQUENCE = "\033[30;46m"; - private const string SELECTED_ROW_FOCUSED_SEQUENCE = "\033[5;30;46m"; - private const string MOVE_ROW_SEQUENCE = "\033[30;43m"; - private const string MOVE_ROW_FOCUSED_SEQUENCE = "\033[5;30;43m"; + private const string SELECTED_ROW_SEQUENCE = EditorColorScheme::SELECTED_ROW_SEQUENCE; + private const string SELECTED_ROW_FOCUSED_SEQUENCE = EditorColorScheme::SELECTED_ROW_FOCUSED_SEQUENCE; + private const string MOVE_ROW_SEQUENCE = EditorColorScheme::EDITING_SEQUENCE; + private const string MOVE_ROW_FOCUSED_SEQUENCE = EditorColorScheme::EDITING_FOCUSED_SEQUENCE; private const string GAME_OBJECT_TYPE = 'Sendama\\Engine\\Core\\GameObject'; private const string GUI_TEXTURE_TYPE = 'Sendama\\Engine\\UI\\GUITexture\\GUITexture'; private const string LABEL_TYPE = 'Sendama\\Engine\\UI\\Label\\Label'; diff --git a/src/Editor/Widgets/InspectorPanel.php b/src/Editor/Widgets/InspectorPanel.php index 21e5ed3..93e0626 100644 --- a/src/Editor/Widgets/InspectorPanel.php +++ b/src/Editor/Widgets/InspectorPanel.php @@ -11,7 +11,9 @@ use ReflectionProperty; use ReflectionUnionType; use Sendama\Engine\IO\Enumerations\Color as EngineColor; +use Sendama\Console\Editor\EditorColorScheme; use Sendama\Console\Editor\PrefabLoader; +use Sendama\Console\Editor\ProjectAutoloadLoader; use Throwable; use Sendama\Console\Editor\FocusTargetContext; use Sendama\Console\Editor\IO\Enumerations\KeyCode; @@ -26,7 +28,9 @@ use Sendama\Console\Editor\Widgets\Controls\PreviewWindowControl; use Sendama\Console\Editor\Widgets\Controls\SectionControl; use Sendama\Console\Editor\Widgets\Controls\SelectInputControl; +use Sendama\Console\Editor\Widgets\Controls\SliderInputControl; use Sendama\Console\Editor\Widgets\Controls\TextInputControl; +use Sendama\Console\Editor\Widgets\Controls\UIElementReferenceInputControl; use Sendama\Console\Editor\Widgets\Controls\VectorInputControl; class InspectorPanel extends Widget @@ -38,12 +42,13 @@ class InspectorPanel extends Widget private const string STATE_PATH_INPUT_ACTION_SELECTION = 'path_input_action_selection'; private const string STATE_PATH_INPUT_FILE_DIALOG = 'path_input_file_dialog'; private const string STATE_PREFAB_REFERENCE_SELECTION = 'prefab_reference_selection'; - private const string SECTION_HEADER_SEQUENCE = "\033[30;47m"; - private const string SECTION_HEADER_SELECTED_SEQUENCE = "\033[30;104m"; - private const string SELECTED_CONTROL_SEQUENCE = "\033[30;46m"; - private const string SELECTED_CONTROL_ACTIVE_SEQUENCE = "\033[5;30;46m"; - private const string EDITING_CONTROL_SEQUENCE = "\033[30;43m"; - private const string EDITING_CONTROL_ACTIVE_SEQUENCE = "\033[5;30;43m"; + private const string STATE_UI_ELEMENT_REFERENCE_SELECTION = 'ui_element_reference_selection'; + private const string SECTION_HEADER_SEQUENCE = EditorColorScheme::SURFACE_SEQUENCE; + private const string SECTION_HEADER_SELECTED_SEQUENCE = EditorColorScheme::SELECTED_ROW_SEQUENCE; + private const string SELECTED_CONTROL_SEQUENCE = EditorColorScheme::SELECTED_ROW_SEQUENCE; + private const string SELECTED_CONTROL_ACTIVE_SEQUENCE = EditorColorScheme::SELECTED_ROW_FOCUSED_SEQUENCE; + private const string EDITING_CONTROL_SEQUENCE = EditorColorScheme::EDITING_SEQUENCE; + private const string EDITING_CONTROL_ACTIVE_SEQUENCE = EditorColorScheme::EDITING_FOCUSED_SEQUENCE; private const array DEFAULT_COMPONENT_CANDIDATES = [ 'Sendama\\Engine\\Core\\Behaviours\\SimpleQuitListener', 'Sendama\\Engine\\Core\\Behaviours\\SimpleBackListener', @@ -69,8 +74,10 @@ class InspectorPanel extends Widget protected OptionListModal $addComponentModal; protected OptionListModal $deleteComponentModal; protected OptionListModal $prefabReferenceModal; + protected OptionListModal $uiElementReferenceModal; protected ?PathInputControl $activePathInputControl = null; protected ?PrefabReferenceInputControl $activePrefabReferenceControl = null; + protected ?UIElementReferenceInputControl $activeUIElementReferenceControl = null; protected array $controlBindings = []; protected array $controlMetadata = []; protected ?array $pendingHierarchyMutation = null; @@ -83,11 +90,15 @@ class InspectorPanel extends Widget protected bool $isComponentMoveModeActive = false; protected ?int $pendingComponentDeletionIndex = null; protected array $prefabReferenceOptions = []; + protected array $uiElementReferenceOptions = []; protected string $modeHelpLabel = ''; protected bool $shouldRefreshModalBackground = false; protected ?int $lastClickedControlIndex = null; protected float $lastClickedControlAt = 0.0; + protected array $classImportAliasCache = []; private const string GUI_TEXTURE_TYPE = 'Sendama\\Engine\\UI\\GUITexture\\GUITexture'; + private const string UI_ELEMENT_TYPE = 'Sendama\\Engine\\UI\\UIElement'; + private const string UI_ELEMENT_INTERFACE_TYPE = 'Sendama\\Engine\\UI\\Interfaces\\UIElementInterface'; private const array GUI_TEXTURE_COLOR_OPTIONS = [ 'Black', 'Dark Gray', @@ -121,6 +132,7 @@ public function __construct( $this->addComponentModal = new OptionListModal(title: 'Add Component'); $this->deleteComponentModal = new OptionListModal(title: 'Remove Component'); $this->prefabReferenceModal = new OptionListModal(title: 'Choose Prefab'); + $this->uiElementReferenceModal = new OptionListModal(title: 'Choose UI Element'); $this->projectDirectory = is_string($workingDirectory) && $workingDirectory !== '' ? $workingDirectory : (getcwd() ?: '.'); @@ -181,8 +193,10 @@ public function inspectTarget(?array $target): void $this->addComponentModal->hide(); $this->deleteComponentModal->hide(); $this->prefabReferenceModal->hide(); + $this->uiElementReferenceModal->hide(); $this->activePathInputControl = null; $this->activePrefabReferenceControl = null; + $this->activeUIElementReferenceControl = null; $this->controlBindings = []; $this->controlMetadata = []; $this->pendingHierarchyMutation = null; @@ -192,6 +206,7 @@ public function inspectTarget(?array $target): void $this->isComponentMoveModeActive = false; $this->pendingComponentDeletionIndex = null; $this->prefabReferenceOptions = []; + $this->uiElementReferenceOptions = []; if ($target === null) { $this->content = []; @@ -260,7 +275,8 @@ public function hasActiveModal(): bool || $this->fileDialogModal->isVisible() || $this->addComponentModal->isVisible() || $this->deleteComponentModal->isVisible() - || $this->prefabReferenceModal->isVisible(); + || $this->prefabReferenceModal->isVisible() + || $this->uiElementReferenceModal->isVisible(); } public function isModalDirty(): bool @@ -269,7 +285,8 @@ public function isModalDirty(): bool || $this->fileDialogModal->isDirty() || $this->addComponentModal->isDirty() || $this->deleteComponentModal->isDirty() - || $this->prefabReferenceModal->isDirty(); + || $this->prefabReferenceModal->isDirty() + || $this->uiElementReferenceModal->isDirty(); } public function markModalClean(): void @@ -279,6 +296,7 @@ public function markModalClean(): void $this->addComponentModal->markClean(); $this->deleteComponentModal->markClean(); $this->prefabReferenceModal->markClean(); + $this->uiElementReferenceModal->markClean(); } public function syncModalLayout(int $terminalWidth, int $terminalHeight): void @@ -288,6 +306,7 @@ public function syncModalLayout(int $terminalWidth, int $terminalHeight): void $this->addComponentModal->syncLayout($terminalWidth, $terminalHeight); $this->deleteComponentModal->syncLayout($terminalWidth, $terminalHeight); $this->prefabReferenceModal->syncLayout($terminalWidth, $terminalHeight); + $this->uiElementReferenceModal->syncLayout($terminalWidth, $terminalHeight); } public function renderActiveModal(): void @@ -311,6 +330,10 @@ public function renderActiveModal(): void if ($this->prefabReferenceModal->isVisible()) { $this->prefabReferenceModal->render(); } + + if ($this->uiElementReferenceModal->isVisible()) { + $this->uiElementReferenceModal->render(); + } } public function handleModalMouseEvent(MouseEvent $mouseEvent): bool @@ -375,6 +398,26 @@ public function handleModalMouseEvent(MouseEvent $mouseEvent): bool return $isWithinModal; } + if ($this->uiElementReferenceModal->isVisible()) { + if ($this->uiElementReferenceModal->handleScrollbarMouseEvent($mouseEvent)) { + return true; + } + + $isWithinModal = $this->uiElementReferenceModal->containsPoint($mouseEvent->x, $mouseEvent->y); + + if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') { + return $isWithinModal; + } + + $selection = $this->uiElementReferenceModal->clickOptionAtPoint($mouseEvent->x, $mouseEvent->y); + + if (is_string($selection) && $selection !== '') { + $this->applyUIElementReferenceSelection($selection); + } + + return $isWithinModal; + } + if ($this->pathInputActionModal->isVisible()) { if ($this->pathInputActionModal->handleScrollbarMouseEvent($mouseEvent)) { return true; @@ -576,6 +619,11 @@ public function update(): void return; } + if ($this->uiElementReferenceModal->isVisible()) { + $this->handleUIElementReferenceModalInput(); + return; + } + if ($this->interactionState === self::STATE_PATH_INPUT_ACTION_SELECTION) { $this->handlePathInputActionInput(); return; @@ -961,6 +1009,11 @@ private function addScriptComponents(mixed $components): void $componentFieldTypes = is_array($component['__editorFieldTypes'] ?? null) ? $component['__editorFieldTypes'] : []; + $componentFieldSchemas = $this->resolveComponentFieldSchemas( + is_string($component['class'] ?? null) ? $component['class'] : null, + $componentFieldTypes, + $serializedComponentData ?? [], + ); if (is_array($serializedComponentData)) { $this->addControl( @@ -976,7 +1029,7 @@ private function addScriptComponents(mixed $components): void $serializedComponentData, ['components', $componentIndex, 'data'], 1, - $componentFieldTypes, + $componentFieldSchemas, ); continue; } @@ -1005,7 +1058,7 @@ private function addScriptComponents(mixed $components): void $legacyComponentData, ['components', $componentIndex], 1, - $componentFieldTypes, + $componentFieldSchemas, ); } } @@ -1014,36 +1067,40 @@ private function addComponentPropertyControls( array $properties, array $basePath, int $indentLevel = 1, - array $fieldTypes = [], + array $fieldSchemas = [], ): void { foreach ($properties as $key => $value) { - if (!is_string($key)) { - continue; - } + $propertySchema = is_array($fieldSchemas[$key] ?? null) + ? $fieldSchemas[$key] + : []; + $label = is_string($key) + ? $this->humanizeKey($key) + : 'Item ' . (((int) $key) + 1); - if ($this->shouldRenderNestedComponentProperties($value)) { + if ($this->shouldRenderNestedComponentProperties($value, $propertySchema)) { $this->addControl($this->addSectionHeader( - $this->humanizeKey($key), + $label, $indentLevel, )); $this->addComponentPropertyControls( - $value, + is_array($value) ? $value : [], [...$basePath, $key], $indentLevel + 1, - is_array($fieldTypes[$key] ?? null) ? $fieldTypes[$key] : [], + $this->resolveNestedComponentFieldSchemas($propertySchema, $value), ); continue; } $this->addBoundControl( $this->buildComponentPropertyControl( - $this->humanizeKey($key), + $label, $value, $indentLevel, - is_string($fieldTypes[$key] ?? null) ? $fieldTypes[$key] : null, + $propertySchema, ), [...$basePath, $key], + $this->buildComponentControlMetadata($propertySchema), ); } } @@ -1052,8 +1109,22 @@ private function buildComponentPropertyControl( string $label, mixed $value, int $indentLevel, - ?string $fieldType = null, + array $fieldSchema = [], ): InputControl { + $fieldType = $this->resolveFieldSchemaType($fieldSchema); + $range = is_array($fieldSchema['range'] ?? null) ? $fieldSchema['range'] : null; + + if ($range !== null && $this->isNumericFieldSchema($fieldSchema, $value)) { + return new SliderInputControl( + $label, + $value, + $range['min'] ?? 0, + $range['max'] ?? 0, + $range['step'] ?? 1, + $indentLevel, + ); + } + if ($this->isPrefabAssignableGameObjectField($fieldType)) { return new PrefabReferenceInputControl( $label, @@ -1063,6 +1134,27 @@ private function buildComponentPropertyControl( ); } + $uiElementFieldType = $this->resolveAssignableUIElementFieldType($fieldType); + + if (is_string($uiElementFieldType)) { + return new UIElementReferenceInputControl( + $label, + $value, + $this->resolveUIElementDisplayLabelsByName($uiElementFieldType), + $indentLevel, + ); + } + + if ($this->isTextureAssignableField($fieldType)) { + return new PathInputControl( + $label, + $this->normalizeTextureComponentFieldValue($value), + $this->resolveAssetsWorkingDirectory(), + ['texture'], + $indentLevel, + ); + } + return $this->inputControlFactory->createForFieldType($label, $value, $fieldType, $indentLevel); } @@ -1089,19 +1181,578 @@ private function isPrefabAssignableGameObjectField(?string $fieldType): bool return false; } - private function shouldRenderNestedComponentProperties(mixed $value): bool + private function resolveAssignableUIElementFieldType(?string $fieldType): ?string + { + if (!is_string($fieldType) || trim($fieldType) === '') { + return null; + } + + $normalizedTypes = array_map( + static fn(string $type): string => ltrim(trim($type), '\\'), + explode('|', $fieldType), + ); + + foreach ($normalizedTypes as $normalizedType) { + if ($normalizedType === '' || $normalizedType === 'null') { + continue; + } + + if (in_array($normalizedType, [self::UI_ELEMENT_TYPE, self::UI_ELEMENT_INTERFACE_TYPE], true)) { + return self::UI_ELEMENT_TYPE; + } + + if ($this->isKnownEngineUIElementType($normalizedType)) { + return $normalizedType; + } + + if ((class_exists($normalizedType) || interface_exists($normalizedType)) && is_a($normalizedType, self::UI_ELEMENT_TYPE, true)) { + return $normalizedType; + } + } + + return null; + } + + private function isTextureAssignableField(?string $fieldType): bool + { + if (!is_string($fieldType) || trim($fieldType) === '') { + return false; + } + + $normalizedTypes = array_map( + static fn(string $type): string => ltrim(trim($type), '\\'), + explode('|', $fieldType), + ); + + return in_array('Sendama\\Engine\\Core\\Texture', $normalizedTypes, true); + } + + private function shouldRenderNestedComponentProperties(mixed $value, array $fieldSchema = []): bool + { + $hasNestedSchema = is_array($fieldSchema['properties'] ?? null) + || is_array($fieldSchema['item'] ?? null); + + if (!is_array($value)) { + return false; + } + + if ($value === []) { + return $hasNestedSchema; + } + + if ($this->isVectorValue($value)) { + return false; + } + + if (array_is_list($value)) { + return true; + } + + return $hasNestedSchema || !$this->isScalarListValue($value); + } + + private function resolveComponentFieldSchemas( + ?string $componentClass, + array $fieldTypes, + array $properties, + ): array { + $schemas = []; + $reflection = $this->reflectProjectClass($componentClass); + + foreach ($properties as $propertyName => $propertyValue) { + $fallbackType = is_string($fieldTypes[$propertyName] ?? null) + ? $fieldTypes[$propertyName] + : null; + + if ( + $reflection instanceof ReflectionClass + && is_string($propertyName) + && $reflection->hasProperty($propertyName) + ) { + try { + $property = $reflection->getProperty($propertyName); + $schemas[$propertyName] = $this->resolveComponentPropertyFieldSchema( + $property, + $propertyValue, + $fallbackType, + ); + continue; + } catch (Throwable) { + } + } + + $schemas[$propertyName] = $this->buildComponentFieldSchema($fallbackType, $propertyValue); + } + + return $schemas; + } + + private function resolveComponentPropertyFieldSchema( + ReflectionProperty $property, + mixed $currentValue, + ?string $fallbackType = null, + ): array { + $resolvedType = $this->resolveReflectionPropertyType($property) ?? $fallbackType; + $schema = $this->buildComponentFieldSchema( + $resolvedType, + $currentValue, + $property->getDeclaringClass(), + ); + $range = $this->resolveRangeAttributeMetadata($property); + + if ($range !== null) { + $schema['range'] = $range; + } + + $collectionItemType = $this->resolveCollectionItemFieldType($property); + + if ($collectionItemType !== null) { + $schema['item'] = $this->buildComponentFieldSchema( + $collectionItemType, + $this->resolveRepresentativeCollectionValue($currentValue), + $property->getDeclaringClass(), + ); + } + + return $schema; + } + + private function buildComponentFieldSchema( + ?string $fieldType, + mixed $currentValue, + ?ReflectionClass $scope = null, + ): array { + $schema = []; + + if (is_string($fieldType) && trim($fieldType) !== '') { + $schema['type'] = $fieldType; + } + + $primaryType = $this->resolvePrimaryFieldTypeName($fieldType); + + if ($primaryType !== null && $this->isCompoundStructureType($primaryType)) { + $schema['properties'] = $this->resolveCompoundStructureFieldSchemas( + $primaryType, + is_array($currentValue) ? $currentValue : [], + ); + } + + if ( + !isset($schema['item']) + && is_array($currentValue) + && array_is_list($currentValue) + && $currentValue !== [] + ) { + $schema['item'] = $this->buildComponentFieldSchema( + null, + $this->resolveRepresentativeCollectionValue($currentValue), + $scope, + ); + } + + return $schema; + } + + private function resolveNestedComponentFieldSchemas(array $fieldSchema, mixed $value): array + { + if (!is_array($value)) { + return []; + } + + if (array_is_list($value)) { + $itemSchema = is_array($fieldSchema['item'] ?? null) + ? $fieldSchema['item'] + : []; + + if ($itemSchema === []) { + return []; + } + + $schemas = []; + + foreach (array_keys($value) as $index) { + $schemas[$index] = $itemSchema; + } + + return $schemas; + } + + return is_array($fieldSchema['properties'] ?? null) + ? $fieldSchema['properties'] + : []; + } + + private function buildComponentControlMetadata(array $fieldSchema): array + { + if ($fieldSchema === []) { + return []; + } + + $metadata = [ + 'fieldSchema' => $fieldSchema, + ]; + $fieldType = $this->resolveFieldSchemaType($fieldSchema); + + if ($fieldType !== null) { + $metadata['fieldType'] = $fieldType; + } + + return $metadata; + } + + private function resolveFieldSchemaType(array $fieldSchema): ?string + { + $fieldType = $fieldSchema['type'] ?? null; + + return is_string($fieldType) && trim($fieldType) !== '' + ? $fieldType + : null; + } + + private function isNumericFieldSchema(array $fieldSchema, mixed $value): bool + { + if (is_int($value) || is_float($value)) { + return true; + } + + $fieldType = $this->resolveFieldSchemaType($fieldSchema); + + if (!is_string($fieldType) || trim($fieldType) === '') { + return false; + } + + $normalizedTypes = array_map( + static fn(string $type): string => strtolower(trim($type)), + explode('|', $fieldType), + ); + + return in_array('int', $normalizedTypes, true) || in_array('float', $normalizedTypes, true); + } + + private function resolveRepresentativeCollectionValue(mixed $value): mixed { if (!is_array($value) || $value === []) { + return null; + } + + foreach ($value as $item) { + return $item; + } + + return null; + } + + private function resolvePrimaryFieldTypeName(?string $fieldType): ?string + { + if (!is_string($fieldType) || trim($fieldType) === '') { + return null; + } + + foreach (explode('|', $fieldType) as $candidateType) { + $normalizedType = ltrim(trim($candidateType), '\\'); + + if ($normalizedType === '' || strtolower($normalizedType) === 'null') { + continue; + } + + return $normalizedType; + } + + return null; + } + + private function resolveCompoundStructureFieldSchemas(string $typeName, array $currentValue): array + { + $reflection = $this->reflectProjectClass($typeName); + + if (!$reflection instanceof ReflectionClass) { + return []; + } + + $schemas = []; + + foreach ($reflection->getProperties() as $property) { + if ( + $property->isStatic() + || !$this->isSerializableComponentProperty($property) + || (method_exists($property, 'isVirtual') && $property->isVirtual()) + ) { + continue; + } + + $propertyName = $property->getName(); + $schemas[$propertyName] = $this->resolveComponentPropertyFieldSchema( + $property, + $currentValue[$propertyName] ?? null, + ); + } + + return $schemas; + } + + private function isCompoundStructureType(string $typeName): bool + { + $normalizedType = ltrim(trim($typeName), '\\'); + + if ($normalizedType === '' || $this->isBuiltinTypeName($normalizedType)) { + return false; + } + + if ( + enum_exists($normalizedType) + || interface_exists($normalizedType) + || !class_exists($normalizedType) + ) { return false; } - if ($this->isVectorValue($value) || $this->isScalarListValue($value)) { + if (in_array($normalizedType, [ + 'Sendama\\Engine\\Core\\GameObject', + self::UI_ELEMENT_TYPE, + self::UI_ELEMENT_INTERFACE_TYPE, + 'Sendama\\Engine\\Core\\Vector2', + 'Sendama\\Engine\\Core\\Rect', + 'Sendama\\Engine\\Core\\Texture', + 'Sendama\\Engine\\Core\\Sprite', + ], true)) { + return false; + } + + if ( + is_a($normalizedType, 'Sendama\\Engine\\Core\\Component', true) + || is_a($normalizedType, 'Sendama\\Engine\\Core\\GameObject', true) + || is_a($normalizedType, self::UI_ELEMENT_TYPE, true) + ) { return false; } return true; } + private function isBuiltinTypeName(string $typeName): bool + { + return in_array(strtolower($typeName), [ + 'array', + 'bool', + 'callable', + 'false', + 'float', + 'int', + 'iterable', + 'mixed', + 'never', + 'null', + 'object', + 'self', + 'static', + 'string', + 'true', + ], true); + } + + private function resolveCollectionItemFieldType(ReflectionProperty $property): ?string + { + $docComment = $property->getDocComment(); + + if (!is_string($docComment) || $docComment === '') { + return null; + } + + if (preg_match('/@var\s+([^\s]+)/', $docComment, $matches) !== 1) { + return null; + } + + $typeExpression = trim($matches[1]); + $collectionItemType = $this->extractCollectionItemTypeExpression($typeExpression); + + if ($collectionItemType === null) { + return null; + } + + return $this->resolveDocblockTypeReference( + $property->getDeclaringClass(), + $collectionItemType, + ); + } + + private function extractCollectionItemTypeExpression(string $typeExpression): ?string + { + $normalizedExpression = trim($typeExpression); + + if ($normalizedExpression === '') { + return null; + } + + $unionMembers = array_values(array_filter(array_map('trim', explode('|', $normalizedExpression)))); + + foreach ($unionMembers as $unionMember) { + if (strtolower($unionMember) === 'null') { + continue; + } + + if (preg_match('/^(.+)\[\]$/', $unionMember, $matches) === 1) { + return trim($matches[1]); + } + + if (preg_match('/^(?:array|list)<(.+)>$/', $unionMember, $matches) === 1) { + $innerType = trim($matches[1]); + $segments = array_values(array_filter(array_map('trim', explode(',', $innerType)))); + + return $segments === [] ? null : end($segments); + } + } + + return null; + } + + private function resolveDocblockTypeReference(ReflectionClass $scope, string $typeReference): ?string + { + $normalizedTypeReference = trim($typeReference); + + if ($normalizedTypeReference === '') { + return null; + } + + if ($normalizedTypeReference[0] === '\\') { + return ltrim($normalizedTypeReference, '\\'); + } + + if ($this->isBuiltinTypeName($normalizedTypeReference)) { + return strtolower($normalizedTypeReference); + } + + if (str_contains($normalizedTypeReference, '\\')) { + return ltrim($normalizedTypeReference, '\\'); + } + + $importAliases = $this->resolveClassImportAliases($scope); + $normalizedAlias = strtolower($normalizedTypeReference); + + if (isset($importAliases[$normalizedAlias])) { + return $importAliases[$normalizedAlias]; + } + + $namespace = $scope->getNamespaceName(); + + return $namespace !== '' + ? $namespace . '\\' . $normalizedTypeReference + : $normalizedTypeReference; + } + + private function resolveClassImportAliases(ReflectionClass $scope): array + { + $scopeName = $scope->getName(); + + if (array_key_exists($scopeName, $this->classImportAliasCache)) { + return $this->classImportAliasCache[$scopeName]; + } + + $fileName = $scope->getFileName(); + + if (!is_string($fileName) || !is_file($fileName)) { + return $this->classImportAliasCache[$scopeName] = []; + } + + $source = file_get_contents($fileName); + + if (!is_string($source) || $source === '') { + return $this->classImportAliasCache[$scopeName] = []; + } + + $aliases = []; + + if (preg_match_all('/^\s*use\s+([^;]+);/mi', $source, $matches) === 1 || count($matches[1] ?? []) > 0) { + foreach ($matches[1] as $importClause) { + if (!is_string($importClause) || str_contains($importClause, '{')) { + continue; + } + + $normalizedClause = trim($importClause); + $alias = basename(str_replace('\\', '/', $normalizedClause)); + $typeReference = $normalizedClause; + + if (preg_match('/^(.+)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i', $normalizedClause, $aliasMatches) === 1) { + $typeReference = trim($aliasMatches[1]); + $alias = trim($aliasMatches[2]); + } + + $aliases[strtolower($alias)] = ltrim($typeReference, '\\'); + } + } + + return $this->classImportAliasCache[$scopeName] = $aliases; + } + + private function resolveRangeAttributeMetadata(ReflectionProperty $property): ?array + { + $attributes = $property->getAttributes('Sendama\\Engine\\Core\\Attributes\\Range'); + + if ($attributes === []) { + return null; + } + + try { + $attribute = $attributes[0]->newInstance(); + $minimum = $attribute->min ?? null; + $maximum = $attribute->max ?? null; + $step = $attribute->step ?? 1; + } catch (Throwable) { + return null; + } + + if (!is_int($minimum) && !is_float($minimum)) { + return null; + } + + if (!is_int($maximum) && !is_float($maximum)) { + return null; + } + + if (!is_int($step) && !is_float($step)) { + $step = 1; + } + + if ($step == 0) { + $step = 1; + } + + if ($minimum > $maximum) { + [$minimum, $maximum] = [$maximum, $minimum]; + } + + return [ + 'min' => $minimum, + 'max' => $maximum, + 'step' => abs($step), + ]; + } + + private function reflectProjectClass(?string $className): ?ReflectionClass + { + if (!is_string($className) || trim($className) === '') { + return null; + } + + $this->ensureProjectAutoloadLoaded(); + $normalizedClassName = ltrim(trim($className), '\\'); + + if (!class_exists($normalizedClassName)) { + return null; + } + + try { + return new ReflectionClass($normalizedClassName); + } catch (Throwable) { + return null; + } + } + + private function ensureProjectAutoloadLoaded(): void + { + $autoloadPath = Path::join($this->projectDirectory, 'vendor', 'autoload.php'); + ProjectAutoloadLoader::load($autoloadPath); + } + private function isVectorValue(array $value): bool { foreach (array_keys($value) as $key) { @@ -1195,6 +1846,7 @@ private function refreshContent(): void $controlIndex = array_search($control, $this->focusableControls, true); $lineControlIndex = is_int($controlIndex) ? $controlIndex : null; + $control->setAvailableWidth($this->resolveControlRenderWidth()); foreach ($control->renderLineDefinitions() as $lineDefinition) { $content[] = $lineDefinition['text'] ?? ''; @@ -1235,6 +1887,12 @@ private function updateHelpInfo(): void return; } + if ($this->uiElementReferenceModal->isVisible()) { + $this->help = 'Up/Down choose Enter assign Esc cancel'; + $this->modeHelpLabel = 'Mode: UI Element Picker'; + return; + } + if ($this->interactionState === self::STATE_PATH_INPUT_ACTION_SELECTION) { $this->help = 'Up/Down choose Enter select Esc cancel'; $this->modeHelpLabel = 'Mode: Path Action'; @@ -1247,9 +1905,21 @@ private function updateHelpInfo(): void return; } + if ($this->interactionState === self::STATE_UI_ELEMENT_REFERENCE_SELECTION) { + $this->help = 'Up/Down choose Enter assign Esc cancel'; + $this->modeHelpLabel = 'Mode: UI Element Assign'; + return; + } + $selectedControl = $this->getSelectedControl(); if ($this->interactionState === self::STATE_CONTROL_EDIT) { + if ($selectedControl instanceof SliderInputControl) { + $this->help = 'Left/Right adjust Enter save Esc cancel'; + $this->modeHelpLabel = 'Mode: Slider Edit'; + return; + } + if ($selectedControl instanceof NumberInputControl) { $this->help = 'Type edit Up/Down adjust Left/Right move Enter save Esc cancel'; $this->modeHelpLabel = 'Mode: Number Edit'; @@ -1309,10 +1979,27 @@ private function updateHelpInfo(): void return; } + if ($selectedControl instanceof UIElementReferenceInputControl) { + $this->help = 'Up/Down select Enter choose UI element Tab next'; + $this->modeHelpLabel = 'Mode: Control Select'; + return; + } + $this->help = 'Up/Down select Enter edit Shift+A add Tab next'; $this->modeHelpLabel = 'Mode: Control Select'; } + private function resolveControlRenderWidth(): int + { + return max( + 0, + $this->innerWidth + - max(0, $this->padding->leftPadding) + - max(0, $this->padding->rightPadding) + - 1, + ); + } + private function buildSplitHelpBorder(string $leftLabel, string $rightLabel): string { $availableLabelWidth = max(0, $this->width - 3); @@ -1503,6 +2190,11 @@ private function activateSelectedControl(InputControl $selectedControl): void return; } + if ($selectedControl instanceof UIElementReferenceInputControl) { + $this->showUIElementReferenceModal($selectedControl); + return; + } + if ($selectedControl instanceof PathInputControl) { $this->showPathInputActionModal($selectedControl); return; @@ -1578,11 +2270,19 @@ private function handleControlEditInput(InputControl $selectedControl): void return; } - if (Input::isKeyPressed(KeyCode::UP) && $selectedControl->increment()) { + if ( + !$selectedControl instanceof SliderInputControl + && Input::isKeyPressed(KeyCode::UP) + && $selectedControl->increment() + ) { return; } - if (Input::isKeyPressed(KeyCode::DOWN) && $selectedControl->decrement()) { + if ( + !$selectedControl instanceof SliderInputControl + && Input::isKeyPressed(KeyCode::DOWN) + && $selectedControl->decrement() + ) { return; } @@ -1634,6 +2334,7 @@ private function resetInteractionState(): void $this->closeAddComponentModal(); $this->closeDeleteComponentModal(); $this->closePrefabReferenceModal(); + $this->closeUIElementReferenceModal(); $selectedControl = $this->getSelectedControl(); if ($selectedControl instanceof CompoundInputControl) { @@ -1817,6 +2518,41 @@ private function showPrefabReferenceModal(PrefabReferenceInputControl $control): $this->syncModalLayout($terminalWidth, $terminalHeight); } + private function showUIElementReferenceModal(UIElementReferenceInputControl $control): void + { + $this->activeUIElementReferenceControl = $control; + $fieldType = $this->resolveSelectedControlAssignableUIElementType($control); + $this->uiElementReferenceOptions = $this->resolveAvailableUIElementReferenceOptions($fieldType); + $options = ['None', ...array_keys($this->uiElementReferenceOptions), 'Cancel']; + $selectedIndex = 0; + $currentValue = $control->getValue(); + + if (is_string($currentValue) && $currentValue !== '') { + foreach ($this->uiElementReferenceOptions as $label => $definition) { + if (($definition['name'] ?? null) === $currentValue) { + $optionIndex = array_search($label, $options, true); + + if (is_int($optionIndex)) { + $selectedIndex = $optionIndex; + } + + break; + } + } + } + + $modalTitle = $fieldType === null || $fieldType === self::UI_ELEMENT_TYPE + ? 'Choose UI Element' + : 'Choose ' . $this->shortTypeName($fieldType); + + $this->uiElementReferenceModal->show($options, $selectedIndex, $modalTitle); + $this->interactionState = self::STATE_UI_ELEMENT_REFERENCE_SELECTION; + $terminalSize = get_max_terminal_size(); + $terminalWidth = $terminalSize['width'] ?? DEFAULT_TERMINAL_WIDTH; + $terminalHeight = $terminalSize['height'] ?? DEFAULT_TERMINAL_HEIGHT; + $this->syncModalLayout($terminalWidth, $terminalHeight); + } + private function handlePrefabReferenceModalInput(): void { if (Input::isKeyDown(KeyCode::ESCAPE)) { @@ -1843,6 +2579,32 @@ private function handlePrefabReferenceModalInput(): void $this->applyPrefabReferenceSelection($this->prefabReferenceModal->getSelectedOption()); } + private function handleUIElementReferenceModalInput(): void + { + if (Input::isKeyDown(KeyCode::ESCAPE)) { + $this->closeUIElementReferenceModal(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + $this->refreshContent(); + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $this->uiElementReferenceModal->moveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->uiElementReferenceModal->moveSelection(1); + return; + } + + if (!Input::isKeyDown(KeyCode::ENTER)) { + return; + } + + $this->applyUIElementReferenceSelection($this->uiElementReferenceModal->getSelectedOption()); + } + private function closePrefabReferenceModal(): void { $this->prefabReferenceModal->hide(); @@ -1850,6 +2612,13 @@ private function closePrefabReferenceModal(): void $this->prefabReferenceOptions = []; } + private function closeUIElementReferenceModal(): void + { + $this->uiElementReferenceModal->hide(); + $this->activeUIElementReferenceControl = null; + $this->uiElementReferenceOptions = []; + } + private function applyAddComponentSelection(?string $selection): void { if (!is_string($selection) || $selection === '' || $selection === 'Cancel') { @@ -1952,6 +2721,33 @@ private function applyPrefabReferenceSelection(?string $selection): void $this->refreshContent(); } + private function applyUIElementReferenceSelection(?string $selection): void + { + if (!$this->activeUIElementReferenceControl instanceof UIElementReferenceInputControl) { + $this->closeUIElementReferenceModal(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + $this->refreshContent(); + return; + } + + if ($selection === 'Cancel') { + $this->closeUIElementReferenceModal(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + $this->refreshContent(); + return; + } + + $nextValue = $selection === 'None' + ? null + : ($this->uiElementReferenceOptions[$selection]['name'] ?? null); + + $this->activeUIElementReferenceControl->setValue($nextValue); + $this->applyControlValueToInspectionTarget($this->activeUIElementReferenceControl); + $this->closeUIElementReferenceModal(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + $this->refreshContent(); + } + private function requestModalBackgroundRefresh(): void { $this->shouldRefreshModalBackground = true; @@ -2118,10 +2914,7 @@ private function resolveComponentCandidateClasses(array $currentItem): array private function buildFallbackComponentDefinitions(array $candidateClasses): array { $autoloadPath = Path::join($this->projectDirectory, 'vendor', 'autoload.php'); - - if (is_file($autoloadPath)) { - require_once $autoloadPath; - } + ProjectAutoloadLoader::load($autoloadPath); $componentBaseClass = 'Sendama\\Engine\\Core\\Component'; @@ -2362,6 +3155,81 @@ function normalize_editor_value(mixed $value): mixed return $value; } + if (is_a($value, '\Sendama\Engine\Core\Sprite')) { + $normalizedSprite = []; + $texture = method_exists($value, 'getTexture') + ? $value->getTexture() + : (property_exists($value, 'texture') ? $value->texture : null); + $rect = method_exists($value, 'getRect') + ? $value->getRect() + : (property_exists($value, 'rect') ? $value->rect : null); + $pivot = method_exists($value, 'getPivot') + ? $value->getPivot() + : (property_exists($value, 'pivot') ? $value->pivot : null); + + if ($texture !== null) { + $normalizedSprite['texture'] = normalize_editor_value($texture); + } + + if ($rect !== null) { + $normalizedSprite['rect'] = normalize_editor_value($rect); + } + + if ($pivot !== null) { + $normalizedSprite['pivot'] = normalize_editor_value($pivot); + } + + if ($normalizedSprite !== []) { + return $normalizedSprite; + } + } + + if (is_a($value, '\Sendama\Engine\Core\Texture')) { + $path = method_exists($value, 'getPath') + ? $value->getPath() + : (property_exists($value, 'path') ? $value->path : null); + $path = is_string($path) ? trim($path) : ''; + + if ($path !== '') { + $normalizedTexture = ['path' => $path]; + $requestedWidth = method_exists($value, 'getRequestedWidth') ? $value->getRequestedWidth() : null; + $requestedHeight = method_exists($value, 'getRequestedHeight') ? $value->getRequestedHeight() : null; + $color = method_exists($value, 'getColor') ? $value->getColor() : null; + + if (is_int($requestedWidth) && $requestedWidth > 0) { + $normalizedTexture['width'] = $requestedWidth; + } + + if (is_int($requestedHeight) && $requestedHeight > 0) { + $normalizedTexture['height'] = $requestedHeight; + } + + if ($color !== null) { + $normalizedTexture['color'] = normalize_editor_value($color); + } + + return count($normalizedTexture) === 1 + ? $normalizedTexture['path'] + : $normalizedTexture; + } + } + + if ( + (is_a($value, '\Sendama\Engine\Core\Rect') + || (method_exists($value, 'getWidth') && method_exists($value, 'getHeight'))) + && method_exists($value, 'getX') + && method_exists($value, 'getY') + && method_exists($value, 'getWidth') + && method_exists($value, 'getHeight') + ) { + return [ + 'x' => normalize_editor_value($value->getX()), + 'y' => normalize_editor_value($value->getY()), + 'width' => normalize_editor_value($value->getWidth()), + 'height' => normalize_editor_value($value->getHeight()), + ]; + } + if (method_exists($value, 'getX') && method_exists($value, 'getY')) { return [ 'x' => normalize_editor_value($value->getX()), @@ -2387,6 +3255,12 @@ function normalize_editor_value(mixed $value): mixed } } + $compoundValue = extract_compound_editor_value($value); + + if (is_array($compoundValue)) { + return $compoundValue; + } + if ($value instanceof Stringable) { return (string) $value; } @@ -2394,6 +3268,45 @@ function normalize_editor_value(mixed $value): mixed return get_class($value); } +function extract_compound_editor_value(object $value): ?array +{ + $valueClass = $value::class; + + if ( + is_a($valueClass, '\Sendama\Engine\Core\Component', true) + || is_a($valueClass, '\Sendama\Engine\Core\GameObject', true) + || is_a($valueClass, '\Sendama\Engine\UI\UIElement', true) + ) { + return null; + } + + try { + $reflection = new ReflectionObject($value); + } catch (Throwable) { + return null; + } + + $normalized = []; + + foreach ($reflection->getProperties() as $property) { + if ( + $property->isStatic() + || (!$property->isPublic() && $property->getAttributes('Sendama\Engine\Core\Behaviours\Attributes\SerializeField') === []) + || (method_exists($property, 'isVirtual') && $property->isVirtual()) + ) { + continue; + } + + try { + $normalized[$property->getName()] = normalize_editor_value($property->getValue($value)); + } catch (Throwable) { + continue; + } + } + + return $normalized !== [] ? $normalized : null; +} + function build_vector(mixed $value, array $default = ['x' => 0, 'y' => 0]): ?object { if (!class_exists('\Sendama\Engine\Core\Vector2')) { @@ -2551,7 +3464,7 @@ function short_class_name(string $classReference): string } if (is_file($autoloadPath)) { - require $autoloadPath; + @require $autoloadPath; } $definitions = []; @@ -2738,6 +3651,81 @@ private function normalizeEditorValue(mixed $value): mixed return $value; } + if (is_a($value, '\Sendama\Engine\Core\Sprite')) { + $normalizedSprite = []; + $texture = method_exists($value, 'getTexture') + ? $value->getTexture() + : (property_exists($value, 'texture') ? $value->texture : null); + $rect = method_exists($value, 'getRect') + ? $value->getRect() + : (property_exists($value, 'rect') ? $value->rect : null); + $pivot = method_exists($value, 'getPivot') + ? $value->getPivot() + : (property_exists($value, 'pivot') ? $value->pivot : null); + + if ($texture !== null) { + $normalizedSprite['texture'] = $this->normalizeEditorValue($texture); + } + + if ($rect !== null) { + $normalizedSprite['rect'] = $this->normalizeEditorValue($rect); + } + + if ($pivot !== null) { + $normalizedSprite['pivot'] = $this->normalizeEditorValue($pivot); + } + + if ($normalizedSprite !== []) { + return $normalizedSprite; + } + } + + if (is_a($value, '\Sendama\Engine\Core\Texture')) { + $path = method_exists($value, 'getPath') + ? $value->getPath() + : (property_exists($value, 'path') ? $value->path : null); + $path = is_string($path) ? trim($path) : ''; + + if ($path !== '') { + $normalizedTexture = ['path' => $path]; + $requestedWidth = method_exists($value, 'getRequestedWidth') ? $value->getRequestedWidth() : null; + $requestedHeight = method_exists($value, 'getRequestedHeight') ? $value->getRequestedHeight() : null; + $color = method_exists($value, 'getColor') ? $value->getColor() : null; + + if (is_int($requestedWidth) && $requestedWidth > 0) { + $normalizedTexture['width'] = $requestedWidth; + } + + if (is_int($requestedHeight) && $requestedHeight > 0) { + $normalizedTexture['height'] = $requestedHeight; + } + + if ($color !== null) { + $normalizedTexture['color'] = $this->normalizeEditorValue($color); + } + + return count($normalizedTexture) === 1 + ? $normalizedTexture['path'] + : $normalizedTexture; + } + } + + if ( + (is_a($value, '\Sendama\Engine\Core\Rect') + || (method_exists($value, 'getWidth') && method_exists($value, 'getHeight'))) + && method_exists($value, 'getX') + && method_exists($value, 'getY') + && method_exists($value, 'getWidth') + && method_exists($value, 'getHeight') + ) { + return [ + 'x' => $this->normalizeEditorValue($value->getX()), + 'y' => $this->normalizeEditorValue($value->getY()), + 'width' => $this->normalizeEditorValue($value->getWidth()), + 'height' => $this->normalizeEditorValue($value->getHeight()), + ]; + } + if (method_exists($value, 'getX') && method_exists($value, 'getY')) { return [ 'x' => $this->normalizeEditorValue($value->getX()), @@ -2763,6 +3751,12 @@ private function normalizeEditorValue(mixed $value): mixed } } + $compoundValue = $this->extractCompoundEditorValue($value); + + if (is_array($compoundValue)) { + return $compoundValue; + } + if ($value instanceof \Stringable) { return (string) $value; } @@ -2770,6 +3764,72 @@ private function normalizeEditorValue(mixed $value): mixed return get_class($value); } + private function extractCompoundEditorValue(object $value): ?array + { + $valueClass = $value::class; + + if ( + is_a($valueClass, 'Sendama\\Engine\\Core\\Component', true) + || is_a($valueClass, 'Sendama\\Engine\\Core\\GameObject', true) + || is_a($valueClass, self::UI_ELEMENT_TYPE, true) + ) { + return null; + } + + try { + $reflection = new ReflectionObject($value); + } catch (Throwable) { + return null; + } + + $normalized = []; + + foreach ($reflection->getProperties() as $property) { + if ( + $property->isStatic() + || (!$property->isPublic() && $property->getAttributes('Sendama\\Engine\\Core\\Behaviours\\Attributes\\SerializeField') === []) + || (method_exists($property, 'isVirtual') && $property->isVirtual()) + ) { + continue; + } + + try { + $normalized[$property->getName()] = $this->normalizeEditorValue($property->getValue($value)); + } catch (Throwable) { + continue; + } + } + + return $normalized !== [] ? $normalized : null; + } + + private function normalizeTextureComponentFieldValue(mixed $value): string + { + if (is_string($value)) { + $normalizedValue = trim($value); + + return $normalizedValue !== '' ? $normalizedValue : 'None'; + } + + if (is_array($value)) { + $path = $value['path'] ?? null; + + return is_string($path) && trim($path) !== '' + ? trim($path) + : 'None'; + } + + if (is_object($value)) { + $path = $value->path ?? null; + + return is_string($path) && trim($path) !== '' + ? trim($path) + : 'None'; + } + + return 'None'; + } + private function buildUniqueComponentMenuLabel(string $baseLabel, string $componentClass, array &$usedLabels): string { if (!isset($usedLabels[$baseLabel])) { @@ -3470,6 +4530,222 @@ private function resolveAvailablePrefabOptions(): array return $prefabOptions; } + private function resolveUIElementDisplayLabelsByName(?string $fieldType = null): array + { + $displayLabelsByName = []; + + foreach ($this->resolveAvailableUIElementReferenceOptions($fieldType) as $uiElementOption) { + $name = $uiElementOption['name'] ?? null; + $label = $uiElementOption['display'] ?? null; + + if (is_string($name) && $name !== '' && is_string($label) && $label !== '') { + $displayLabelsByName[$name] = $label; + } + } + + return $displayLabelsByName; + } + + private function resolveAvailableUIElementReferenceOptions(?string $fieldType = null): array + { + if ($this->sceneHierarchy === []) { + return []; + } + + $normalizedFieldType = is_string($fieldType) ? $fieldType : self::UI_ELEMENT_TYPE; + $options = []; + $usedLabels = []; + + $this->collectAvailableUIElementReferenceOptions( + $this->sceneHierarchy, + $normalizedFieldType, + $options, + $usedLabels, + ); + + ksort($options); + + return $options; + } + + private function collectAvailableUIElementReferenceOptions( + array $hierarchy, + string $fieldType, + array &$options, + array &$usedLabels, + ): void { + foreach ($hierarchy as $item) { + if (!is_array($item)) { + continue; + } + + $itemType = is_string($item['type'] ?? null) + ? ltrim(trim((string) $item['type']), '\\') + : null; + $itemName = is_string($item['name'] ?? null) + ? trim((string) $item['name']) + : ''; + + if ( + is_string($itemType) + && $itemName !== '' + && $this->isAssignableSceneUIElementType($itemType, $fieldType) + ) { + $labelBase = sprintf('%s (%s)', $itemName, $this->shortTypeName($itemType)); + $label = $this->buildUniqueReferenceOptionLabel($labelBase, $usedLabels); + + $options[$label] = [ + 'name' => $itemName, + 'type' => $itemType, + 'display' => $label, + ]; + } + + $children = $item['children'] ?? null; + + if (is_array($children) && $children !== []) { + $this->collectAvailableUIElementReferenceOptions($children, $fieldType, $options, $usedLabels); + } + } + } + + private function resolveSelectedControlAssignableUIElementType(InputControl $control): ?string + { + $controlMetadata = $this->getSelectedControlMetadata($control); + $fieldTypeFromMetadata = is_string($controlMetadata['fieldType'] ?? null) + ? $controlMetadata['fieldType'] + : $this->resolveFieldSchemaType( + is_array($controlMetadata['fieldSchema'] ?? null) + ? $controlMetadata['fieldSchema'] + : [], + ); + + if (is_string($fieldTypeFromMetadata) && trim($fieldTypeFromMetadata) !== '') { + return $this->resolveAssignableUIElementFieldType($fieldTypeFromMetadata) ?? self::UI_ELEMENT_TYPE; + } + + $controlPath = $this->controlBindings[spl_object_id($control)] ?? null; + + if (!is_array($controlPath) || $controlPath === []) { + return self::UI_ELEMENT_TYPE; + } + + $fieldTypes = $this->resolveCurrentInspectionComponentFieldTypes(); + $resolvedFieldType = $this->resolveFieldTypeForControlPath($fieldTypes, $controlPath); + + return $this->resolveAssignableUIElementFieldType($resolvedFieldType) ?? self::UI_ELEMENT_TYPE; + } + + private function resolveCurrentInspectionComponentFieldTypes(): array + { + if (!is_array($this->inspectionTarget)) { + return []; + } + + $value = $this->inspectionTarget['value'] ?? null; + + if (!is_array($value)) { + return []; + } + + $components = $value['components'] ?? null; + + if (!is_array($components)) { + return []; + } + + $fieldTypes = [ + 'components' => [], + ]; + + foreach ($components as $index => $component) { + if (!is_array($component)) { + continue; + } + + $fieldTypes['components'][$index] = [ + 'data' => is_array($component['__editorFieldTypes'] ?? null) + ? $component['__editorFieldTypes'] + : [], + ]; + } + + return $fieldTypes; + } + + private function resolveFieldTypeForControlPath(array $fieldTypes, array $controlPath): ?string + { + $current = $fieldTypes; + + foreach ($controlPath as $segment) { + if (is_array($current) && array_key_exists($segment, $current)) { + $current = $current[$segment]; + continue; + } + + return null; + } + + return is_string($current) ? $current : null; + } + + private function isAssignableSceneUIElementType(string $itemType, string $fieldType): bool + { + if ($fieldType === self::UI_ELEMENT_TYPE || $fieldType === self::UI_ELEMENT_INTERFACE_TYPE) { + if ($this->isKnownEngineUIElementType($itemType)) { + return true; + } + } + + if ($itemType === $fieldType) { + return true; + } + + if (!(class_exists($itemType) || interface_exists($itemType))) { + return false; + } + + if ($fieldType === self::UI_ELEMENT_TYPE || $fieldType === self::UI_ELEMENT_INTERFACE_TYPE) { + return is_a($itemType, self::UI_ELEMENT_TYPE, true); + } + + return is_a($itemType, $fieldType, true); + } + + private function isKnownEngineUIElementType(string $type): bool + { + $normalizedType = ltrim(trim($type), '\\'); + + if ($normalizedType === '' || str_contains($normalizedType, '\\Interfaces\\')) { + return false; + } + + return str_starts_with($normalizedType, 'Sendama\\Engine\\UI\\'); + } + + private function buildUniqueReferenceOptionLabel(string $baseLabel, array &$usedLabels): string + { + $label = $baseLabel; + $suffix = 2; + + while (isset($usedLabels[$label])) { + $label = sprintf('%s #%d', $baseLabel, $suffix); + $suffix++; + } + + $usedLabels[$label] = true; + + return $label; + } + + private function shortTypeName(string $type): string + { + $normalizedType = ltrim(trim($type), '\\'); + $segments = explode('\\', $normalizedType); + + return $segments[array_key_last($segments)] ?? $normalizedType; + } + private function buildRelativePrefabPath(string $absolutePath): ?string { $assetsDirectory = $this->resolveAssetsWorkingDirectory(); diff --git a/src/Editor/Widgets/MainPanel.php b/src/Editor/Widgets/MainPanel.php index b1fdddd..4197b3e 100644 --- a/src/Editor/Widgets/MainPanel.php +++ b/src/Editor/Widgets/MainPanel.php @@ -4,6 +4,7 @@ use Atatusoft\Termutil\Events\MouseEvent; use Atatusoft\Termutil\IO\Enumerations\Color; +use Sendama\Console\Editor\EditorColorScheme; use Sendama\Console\Editor\FocusTargetContext; use Sendama\Console\Editor\IO\Enumerations\KeyCode; use Sendama\Console\Editor\IO\Input; @@ -18,19 +19,19 @@ class MainPanel extends Widget private const string SCENE_VIEW_MODE_SELECT = 'select'; private const string SCENE_VIEW_MODE_MOVE = 'move'; private const string SCENE_VIEW_MODE_PAN = 'pan'; - private const string SCENE_SELECTION_SEQUENCE = "\033[30;46m"; - private const string SCENE_SELECTION_FOCUSED_SEQUENCE = "\033[5;30;46m"; - private const string SCENE_MOVE_SEQUENCE = "\033[30;43m"; - private const string SCENE_MOVE_FOCUSED_SEQUENCE = "\033[5;30;43m"; - private const string SCENE_PAN_SEQUENCE = "\033[30;44m"; - private const string SCENE_PAN_FOCUSED_SEQUENCE = "\033[5;30;44m"; - private const string SPRITE_CURSOR_SEQUENCE = "\033[30;47m"; - private const string SPRITE_CURSOR_FOCUSED_SEQUENCE = "\033[5;30;47m"; + private const string SCENE_SELECTION_SEQUENCE = EditorColorScheme::SELECTED_ROW_SEQUENCE; + private const string SCENE_SELECTION_FOCUSED_SEQUENCE = EditorColorScheme::SELECTED_ROW_FOCUSED_SEQUENCE; + private const string SCENE_MOVE_SEQUENCE = EditorColorScheme::EDITING_SEQUENCE; + private const string SCENE_MOVE_FOCUSED_SEQUENCE = EditorColorScheme::EDITING_FOCUSED_SEQUENCE; + private const string SCENE_PAN_SEQUENCE = EditorColorScheme::SURFACE_SEQUENCE; + private const string SCENE_PAN_FOCUSED_SEQUENCE = EditorColorScheme::SURFACE_FOCUSED_SEQUENCE; + private const string SPRITE_CURSOR_SEQUENCE = EditorColorScheme::SELECTED_ROW_SEQUENCE; + private const string SPRITE_CURSOR_FOCUSED_SEQUENCE = EditorColorScheme::SELECTED_ROW_FOCUSED_SEQUENCE; private const string GAME_IDLE_PATTERN_CHARACTER = '/'; private const string GAME_IDLE_PROMPT = 'Shift+5 to Play'; private const string SCENE_PLACEHOLDER_CHARACTER = 'x'; - private const Color DEFAULT_FOCUS_COLOR = Color::LIGHT_CYAN; - private const Color PLAY_MODE_FOCUS_COLOR = Color::BROWN; + private const Color DEFAULT_FOCUS_COLOR = EditorColorScheme::PRIMARY_FOCUS_COLOR; + private const Color PLAY_MODE_FOCUS_COLOR = EditorColorScheme::PLAY_MODE_FOCUS_COLOR; private const string SPRITE_MODAL_CREATE = 'create_asset'; private const string SPRITE_MODAL_DELETE = 'delete_asset'; private const string SPRITE_MODAL_CHARACTER = 'character_picker'; @@ -111,7 +112,7 @@ class MainPanel extends Widget protected int $activeTabIndex = 0; protected int $activeTabOffset = 0; protected int $activeTabLength = 0; - protected Color $activeIndicatorColor = Color::LIGHT_CYAN; + protected Color $activeIndicatorColor = EditorColorScheme::ACTIVE_INDICATOR_COLOR; protected bool $isPlayModeActive = false; protected array $gameIdleContentIndexes = []; protected ?int $gameIdlePromptContentIndex = null; @@ -741,12 +742,12 @@ private function colorizeGameIdleMiddle(string $middle, bool $isPromptLine): str $character = mb_substr($middle, $index, 1); if ($isPromptLine && $index >= $promptStart && $index < $promptEnd) { - $output .= $this->wrapWithColor($character, Color::LIGHT_GRAY); + $output .= $this->wrapWithColor($character, EditorColorScheme::PRIMARY_FOCUS_COLOR); continue; } if ($character === self::GAME_IDLE_PATTERN_CHARACTER) { - $output .= $this->wrapWithColor($character, Color::BLUE); + $output .= $this->wrapWithColor($character, EditorColorScheme::MUTED_COLOR); continue; } diff --git a/src/Editor/Widgets/OptionListModal.php b/src/Editor/Widgets/OptionListModal.php index 8c2f635..08b2f97 100644 --- a/src/Editor/Widgets/OptionListModal.php +++ b/src/Editor/Widgets/OptionListModal.php @@ -3,10 +3,11 @@ namespace Sendama\Console\Editor\Widgets; use Atatusoft\Termutil\IO\Enumerations\Color; +use Sendama\Console\Editor\EditorColorScheme; class OptionListModal extends Widget { - private const string SELECTED_ROW_SEQUENCE = "\033[30;46m"; + private const string SELECTED_ROW_SEQUENCE = EditorColorScheme::SELECTED_ROW_SEQUENCE; protected bool $isVisible = false; protected bool $isDirty = false; diff --git a/src/Editor/Widgets/Snackbar.php b/src/Editor/Widgets/Snackbar.php index dab45c8..3fdefdb 100644 --- a/src/Editor/Widgets/Snackbar.php +++ b/src/Editor/Widgets/Snackbar.php @@ -3,6 +3,7 @@ namespace Sendama\Console\Editor\Widgets; use Atatusoft\Termutil\IO\Enumerations\Color; +use Sendama\Console\Editor\EditorColorScheme; final class Snackbar extends Widget { @@ -84,7 +85,7 @@ public function renderAt(?int $x = null, ?int $y = null): void $rightMargin = $leftMargin + $this->width - 1; $bottomMargin = $topMargin + $this->height - 1; - if ($leftMargin > $this->terminalWidth || $rightMargin < 1 || $topMargin < 1 || $topMargin > $this->terminalHeight || $bottomMargin < 1) { + if ($leftMargin > $this->terminalWidth || $rightMargin < 1 || $topMargin > $this->terminalHeight || $bottomMargin < 1) { return; } @@ -307,20 +308,20 @@ private function normalizeStatus(string $status): string private function resolveStatusColor(): Color { return match ($this->currentNotice['status'] ?? 'info') { - 'success' => Color::LIGHT_GREEN, - 'error' => Color::LIGHT_RED, - 'warn' => Color::YELLOW, - default => Color::LIGHT_BLUE, + 'success' => EditorColorScheme::SUCCESS_COLOR, + 'error' => EditorColorScheme::ERROR_COLOR, + 'warn' => EditorColorScheme::WARNING_COLOR, + default => EditorColorScheme::INFO_COLOR, }; } private function resolveStatusSequence(): string { return match ($this->currentNotice['status'] ?? 'info') { - 'success' => "\033[30;42m", - 'error' => "\033[30;41m", - 'warn' => "\033[30;43m", - default => "\033[30;44m", + 'success' => EditorColorScheme::SUCCESS_SEQUENCE, + 'error' => EditorColorScheme::ERROR_SEQUENCE, + 'warn' => EditorColorScheme::WARNING_SEQUENCE, + default => EditorColorScheme::INFO_SEQUENCE, }; } @@ -336,7 +337,7 @@ private function resolveTargetY(): int private function resolveHiddenY(): int { - return 0; + return 1 - $this->height; } private function moveToY(int $y): void diff --git a/src/Editor/Widgets/Widget.php b/src/Editor/Widgets/Widget.php index b6767e9..158ddf2 100644 --- a/src/Editor/Widgets/Widget.php +++ b/src/Editor/Widgets/Widget.php @@ -6,6 +6,7 @@ use Atatusoft\Termutil\IO\Enumerations\Color; use Atatusoft\Termutil\UI\Windows\Enumerations\HorizontalAlignment; use Atatusoft\Termutil\UI\Windows\Window; +use Sendama\Console\Editor\EditorColorScheme; use Sendama\Console\Editor\FocusTargetContext; use Sendama\Console\Editor\Interfaces\FocusableInterface; @@ -48,7 +49,7 @@ abstract class Widget extends Window implements FocusableInterface } protected(set) bool $isEnabled = true; protected bool $hasFocus = false; - protected Color $focusBorderColor = Color::LIGHT_CYAN; + protected Color $focusBorderColor = EditorColorScheme::PRIMARY_FOCUS_COLOR; protected int $verticalScrollOffset = 0; protected bool $isScrollbarDragging = false; diff --git a/tests/Unit/EditorAssetSelectionTest.php b/tests/Unit/EditorAssetSelectionTest.php index ebf7a7c..ae5f826 100644 --- a/tests/Unit/EditorAssetSelectionTest.php +++ b/tests/Unit/EditorAssetSelectionTest.php @@ -7,6 +7,8 @@ use Sendama\Console\Editor\PrefabWriter; use Sendama\Console\Editor\IO\InputManager; use Sendama\Console\Editor\Widgets\AssetsPanel; +use Sendama\Console\Editor\Widgets\CommandHelpModal; +use Sendama\Console\Editor\Widgets\CommandLineModal; use Sendama\Console\Editor\Widgets\ConsolePanel; use Sendama\Console\Editor\Widgets\HierarchyPanel; use Sendama\Console\Editor\Widgets\InspectorPanel; @@ -805,6 +807,8 @@ function createEditorForAssetSelection(string $workspace): array $editorReflection->getProperty('consolePanel')->setValue($editor, $consolePanel); $editorReflection->getProperty('inspectorPanel')->setValue($editor, $inspectorPanel); $editorReflection->getProperty('panelListModal')->setValue($editor, new PanelListModal()); + $editorReflection->getProperty('commandLineModal')->setValue($editor, new CommandLineModal()); + $editorReflection->getProperty('commandHelpModal')->setValue($editor, new CommandHelpModal()); $editorReflection->getProperty('snackbar')->setValue($editor, new Snackbar()); $editorReflection->getProperty('panels')->setValue($editor, new ItemList(\Sendama\Console\Editor\Widgets\Widget::class, [ $hierarchyPanel, @@ -857,6 +861,8 @@ function createEditorForPrefabExport(string $workspace): array $editorReflection->getProperty('inspectorPanel')->setValue($editor, $inspectorPanel); $editorReflection->getProperty('consolePanel')->setValue($editor, $consolePanel); $editorReflection->getProperty('prefabWriter')->setValue($editor, new PrefabWriter()); + $editorReflection->getProperty('commandLineModal')->setValue($editor, new CommandLineModal()); + $editorReflection->getProperty('commandHelpModal')->setValue($editor, new CommandHelpModal()); $editorReflection->getProperty('snackbar')->setValue($editor, new Snackbar()); $editorReflection->getProperty('focusedPanel')->setValue($editor, $hierarchyPanel); diff --git a/tests/Unit/EditorCommandModeTest.php b/tests/Unit/EditorCommandModeTest.php new file mode 100644 index 0000000..d0dfed5 --- /dev/null +++ b/tests/Unit/EditorCommandModeTest.php @@ -0,0 +1,81 @@ +setAccessible(true); + $previousKeyPress->setAccessible(true); + $previousKeyPress->setValue($previous); + $keyPress->setValue($current); +} + +function createBareEditorForCommandMode(): array +{ + $reflection = new ReflectionClass(Editor::class); + $editor = $reflection->newInstanceWithoutConstructor(); + + $reflection->getProperty('panelListModal')->setValue($editor, new PanelListModal()); + $reflection->getProperty('commandLineModal')->setValue($editor, new CommandLineModal()); + $reflection->getProperty('commandHelpModal')->setValue($editor, new CommandHelpModal()); + $reflection->getProperty('snackbar')->setValue($editor, new Snackbar()); + $reflection->getProperty('terminalWidth')->setValue($editor, 120); + $reflection->getProperty('terminalHeight')->setValue($editor, 40); + $reflection->getProperty('shouldRefreshBackgroundUnderModal')->setValue($editor, false); + + return [$reflection, $editor]; +} + +test('editor opens command mode when colon is pressed', function () { + [$reflection, $editor] = createBareEditorForCommandMode(); + $commandLineModal = $reflection->getProperty('commandLineModal'); + $commandLineModal->setAccessible(true); + $handlePanelKeyboardWorkflow = $reflection->getMethod('handlePanelKeyboardWorkflow'); + $handlePanelKeyboardWorkflow->setAccessible(true); + + setEditorInput(':'); + $handlePanelKeyboardWorkflow->invoke($editor); + + /** @var CommandLineModal $modal */ + $modal = $commandLineModal->getValue($editor); + + expect($modal->isVisible())->toBeTrue() + ->and($modal->getInput())->toBe(''); +}); + +test('editor command mode opens the help cheatsheet when help is entered', function () { + [$reflection, $editor] = createBareEditorForCommandMode(); + $commandLineModal = $reflection->getProperty('commandLineModal'); + $commandHelpModal = $reflection->getProperty('commandHelpModal'); + $commandLineModal->setAccessible(true); + $commandHelpModal->setAccessible(true); + $handlePanelKeyboardWorkflow = $reflection->getMethod('handlePanelKeyboardWorkflow'); + $handlePanelKeyboardWorkflow->setAccessible(true); + + setEditorInput(':'); + $handlePanelKeyboardWorkflow->invoke($editor); + + foreach (str_split('help') as $character) { + setEditorInput($character); + $handlePanelKeyboardWorkflow->invoke($editor); + } + + setEditorInput('enter'); + $handlePanelKeyboardWorkflow->invoke($editor); + + /** @var CommandLineModal $lineModal */ + $lineModal = $commandLineModal->getValue($editor); + /** @var CommandHelpModal $helpModal */ + $helpModal = $commandHelpModal->getValue($editor); + + expect($lineModal->isVisible())->toBeFalse() + ->and($helpModal->isVisible())->toBeTrue() + ->and($helpModal->content)->toContain('Type :help to open this cheatsheet.') + ->and($helpModal->content)->toContain(' Ctrl+S save the current scene'); +}); diff --git a/tests/Unit/EditorFileWatchTest.php b/tests/Unit/EditorFileWatchTest.php index 78e7e14..68939ab 100644 --- a/tests/Unit/EditorFileWatchTest.php +++ b/tests/Unit/EditorFileWatchTest.php @@ -7,6 +7,8 @@ use Sendama\Console\Editor\SceneLoader; use Sendama\Console\Editor\SceneWriter; use Sendama\Console\Editor\Widgets\AssetsPanel; +use Sendama\Console\Editor\Widgets\CommandHelpModal; +use Sendama\Console\Editor\Widgets\CommandLineModal; use Sendama\Console\Editor\Widgets\ConsolePanel; use Sendama\Console\Editor\Widgets\HierarchyPanel; use Sendama\Console\Editor\Widgets\InspectorPanel; @@ -232,6 +234,8 @@ function createEditorForFileWatch(string $workspace): array $editorReflection->getProperty('consolePanel')->setValue($editor, $consolePanel); $editorReflection->getProperty('inspectorPanel')->setValue($editor, $inspectorPanel); $editorReflection->getProperty('panelListModal')->setValue($editor, new PanelListModal()); + $editorReflection->getProperty('commandLineModal')->setValue($editor, new CommandLineModal()); + $editorReflection->getProperty('commandHelpModal')->setValue($editor, new CommandHelpModal()); $editorReflection->getProperty('snackbar')->setValue($editor, new Snackbar()); $editorReflection->getProperty('panels')->setValue($editor, new ItemList(Widget::class, [ $hierarchyPanel, diff --git a/tests/Unit/InspectorPanelTest.php b/tests/Unit/InspectorPanelTest.php index 72ce2cb..f06a020 100644 --- a/tests/Unit/InspectorPanelTest.php +++ b/tests/Unit/InspectorPanelTest.php @@ -351,6 +351,147 @@ public function __construct(GameObject $gameObject) return $workspace; } +function createInspectorCompoundComponentWorkspace(): string +{ + $workspace = sys_get_temp_dir() . '/sendama-inspector-compound-components-' . uniqid(); + mkdir($workspace . '/Assets/Scripts', 0777, true); + mkdir($workspace . '/vendor', 0777, true); + + file_put_contents( + $workspace . '/vendor/autoload.php', + <<<'PHP' +x; + } + + public function getY(): int + { + return $this->y; + } + } + + class GameObject + { + public function __construct( + private string $name, + private ?string $tag = null, + private Vector2 $position = new Vector2(), + private Vector2 $rotation = new Vector2(), + private Vector2 $scale = new Vector2(1, 1), + private ?object $sprite = null, + ) { + } + } + + abstract class Component + { + public function __construct(private readonly GameObject $gameObject) + { + } + } +} + +namespace Sendama\Engine\Core\Behaviours { + use Sendama\Engine\Core\Component; + use Sendama\Engine\Core\GameObject; + + abstract class Behaviour extends Component + { + public function __construct(GameObject $gameObject) + { + parent::__construct($gameObject); + } + } +} + +namespace { + require __DIR__ . '/../Assets/Scripts/SchemaProbe.php'; +} +PHP + ); + + file_put_contents( + $workspace . '/Assets/Scripts/SchemaProbe.php', + <<<'PHP' +origin = new Vector2(6, 7); + } +} + +class SchemaProbe extends Behaviour +{ + #[Range(min: 0, max: 10)] + public int $speed = 4; + + /** @var Vector2[] */ + public array $waypoints = []; + + public CompoundSettings $settings; + + public function __construct(GameObject $gameObject) + { + parent::__construct($gameObject); + $this->waypoints = [ + new Vector2(1, 2), + new Vector2(3, 4), + ]; + $this->settings = new CompoundSettings(); + } +} +PHP + ); + + return $workspace; +} + test('inspector panel renders hierarchy object controls and renderer preview', function () { $workspace = sys_get_temp_dir() . '/sendama-inspector-panel-' . uniqid(); mkdir($workspace . '/Assets/Textures', 0777, true); @@ -459,6 +600,137 @@ public function __construct(GameObject $gameObject) ->not->toContain('▼ Renderer'); }); +test('inspector panel filters ui element reference pickers to gui textures when the field is typed as gui texture', function () { + $panel = new InspectorPanel(width: 48, height: 24); + $uiElementReferenceOptions = new ReflectionProperty(InspectorPanel::class, 'uiElementReferenceOptions'); + $uiElementReferenceModal = new ReflectionProperty(InspectorPanel::class, 'uiElementReferenceModal'); + $uiElementReferenceOptions->setAccessible(true); + $uiElementReferenceModal->setAccessible(true); + + $panel->setSceneHierarchy([ + [ + 'type' => 'Sendama\\Engine\\UI\\Label\\Label', + 'name' => 'Score', + 'text' => 'Score: 0', + ], + [ + 'type' => 'Sendama\\Engine\\UI\\GUITexture\\GUITexture', + 'name' => 'Heart #1', + 'texture' => 'Textures/heart.texture', + ], + [ + 'type' => 'Sendama\\Engine\\UI\\GUITexture\\GUITexture', + 'name' => 'Heart #2', + 'texture' => 'Textures/heart.texture', + ], + ]); + + focusInspectorPanel($panel); + $panel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'Level Manager', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Level Manager', + 'components' => [ + [ + 'class' => 'Sendama\\Game\\Scripts\\LevelController', + 'data' => [ + 'heart1' => null, + ], + '__editorFieldTypes' => [ + 'heart1' => 'Sendama\\Engine\\UI\\GUITexture\\GUITexture|null', + ], + ], + ], + ], + ]); + + selectInspectorControlByLabel($panel, 'Heart1'); + setInspectorInput('enter'); + $panel->update(); + + /** @var array $options */ + $options = $uiElementReferenceOptions->getValue($panel); + $modal = $uiElementReferenceModal->getValue($panel); + + expect($modal->isVisible())->toBeTrue() + ->and(array_values(array_map( + static fn(array $option): string => $option['name'], + $options, + )))->toBe(['Heart #1', 'Heart #2']); +}); + +test('inspector panel allows generic ui element fields to pick from all scene ui elements', function () { + $panel = new InspectorPanel(width: 48, height: 24); + $uiElementReferenceOptions = new ReflectionProperty(InspectorPanel::class, 'uiElementReferenceOptions'); + $inspectionTarget = new ReflectionProperty(InspectorPanel::class, 'inspectionTarget'); + $uiElementReferenceOptions->setAccessible(true); + $inspectionTarget->setAccessible(true); + + $panel->setSceneHierarchy([ + [ + 'type' => 'Sendama\\Engine\\UI\\Label\\Label', + 'name' => 'Score', + 'text' => 'Score: 0', + ], + [ + 'type' => 'Sendama\\Engine\\UI\\GUITexture\\GUITexture', + 'name' => 'Heart #1', + 'texture' => 'Textures/heart.texture', + ], + ]); + + focusInspectorPanel($panel); + $panel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'Level Manager', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Level Manager', + 'components' => [ + [ + 'class' => 'Sendama\\Game\\Scripts\\LevelController', + 'data' => [ + 'statusUi' => null, + ], + '__editorFieldTypes' => [ + 'statusUi' => 'Sendama\\Engine\\UI\\UIElement|null', + ], + ], + ], + ], + ]); + + selectInspectorControlByLabel($panel, 'Status Ui'); + setInspectorInput('enter'); + $panel->update(); + + /** @var array $options */ + $options = $uiElementReferenceOptions->getValue($panel); + $scoreLabel = array_key_first(array_filter( + $options, + static fn(array $option): bool => $option['name'] === 'Score', + )); + + expect(array_values(array_map( + static fn(array $option): string => $option['name'], + $options, + )))->toBe(['Heart #1', 'Score']); + + expect($scoreLabel)->toBeString(); + + $applyUIElementReferenceSelection = new ReflectionMethod(InspectorPanel::class, 'applyUIElementReferenceSelection'); + $applyUIElementReferenceSelection->setAccessible(true); + $applyUIElementReferenceSelection->invoke($panel, $scoreLabel); + + expect($inspectionTarget->getValue($panel)['value']['components'][0]['data']['statusUi'] ?? null)->toBe('Score'); +}); + test('inspector panel enters edit mode when a control is double clicked', function () { $panel = new InspectorPanel(width: 48, height: 24); $interactionState = new ReflectionProperty(InspectorPanel::class, 'interactionState'); @@ -608,10 +880,10 @@ public function __construct(GameObject $gameObject) $line = '|' . str_pad($panel->content[3], $panelWidth - 2) . '|'; $renderedLine = $decorateContentLine->invoke($panel, $line, null, 3); - expect($renderedLine)->toContain("\033[30;47m"); + expect($renderedLine)->toContain("\033[97;100m"); }); -test('inspector panel styles focused section headers with a light blue background', function () { +test('inspector panel styles focused section headers with the primary accent background', function () { $panelWidth = 32; $panel = new InspectorPanel(width: $panelWidth, height: 12); @@ -643,7 +915,7 @@ public function __construct(GameObject $gameObject) $line = '|' . str_pad($panel->content[3], $panelWidth - 2) . '|'; $renderedLine = $decorateContentLine->invoke($panel, $line, null, 3); - expect($renderedLine)->toContain("\033[30;104m"); + expect($renderedLine)->toContain("\033[30;101m"); }); test('inspector panel styles component headers with a warm highlight in component move mode', function () { @@ -778,14 +1050,14 @@ public function __construct(GameObject $gameObject) $typeLine = '|' . str_pad($panel->content[0], $panelWidth - 2) . '|'; $renderedTypeLine = $decorateContentLine->invoke($panel, $typeLine, null, 0); - expect($renderedTypeLine)->toContain("\033[30;46m"); + expect($renderedTypeLine)->toContain("\033[30;101m"); $panel->cycleFocusForward(); $nameLine = '|' . str_pad($panel->content[1], $panelWidth - 2) . '|'; $renderedNameLine = $decorateContentLine->invoke($panel, $nameLine, null, 1); - expect($renderedNameLine)->toContain("\033[30;46m"); + expect($renderedNameLine)->toContain("\033[30;101m"); }); test('inspector panel cycles focus backward through controls within the panel', function () { @@ -807,7 +1079,7 @@ public function __construct(GameObject $gameObject) $nameLine = '|' . str_pad($panel->content[1], $panelWidth - 2) . '|'; $renderedNameLine = $decorateContentLine->invoke($panel, $nameLine, null, 1); - expect($renderedNameLine)->toContain("\033[30;46m"); + expect($renderedNameLine)->toContain("\033[30;101m"); }); test('inspector panel keeps generic asset inspection simple', function () { @@ -1198,6 +1470,49 @@ class GameObject expect(implode("\n", $panel->content))->toContain('Bullet Prefab: Enemy'); }); +test('inspector panel treats Texture component fields as texture path pickers', function () { + $workspace = sys_get_temp_dir() . '/sendama-inspector-texture-reference-' . uniqid(); + mkdir($workspace . '/Assets/Textures', 0777, true); + file_put_contents($workspace . '/Assets/Textures/bullet.texture', "<>\n"); + + $panel = new InspectorPanel(width: 48, height: 24, workingDirectory: $workspace); + $panel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + [ + 'class' => 'Sendama\\Game\\Scripts\\Gun', + 'data' => [ + 'bulletTexture' => null, + ], + '__editorFieldTypes' => [ + 'bulletTexture' => 'Sendama\\Engine\\Core\\Texture|null', + ], + ], + ], + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'Bullet Texture'); + + expect($panel->content)->toContain(' Bullet Texture: None'); + + setInspectorInput("\n"); + $panel->update(); + + expect($panel->hasActiveModal())->toBeTrue(); +}); + test('inspector panel renders typed Vector2 component fields as compound controls', function () { $panel = new InspectorPanel(width: 48, height: 24); $panel->inspectTarget([ @@ -1232,6 +1547,165 @@ class GameObject ->toContain(' Y: 0'); }); +test('inspector panel renders range fields as sliders and nested compound structures as controls', function () { + $workspace = createInspectorCompoundComponentWorkspace(); + $panel = new InspectorPanel(width: 64, height: 32, workingDirectory: $workspace); + $panel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + [ + 'class' => 'Sendama\\Game\\Scripts\\SchemaProbe', + 'data' => [ + 'speed' => 4, + 'waypoints' => [ + ['x' => 1, 'y' => 2], + ['x' => 3, 'y' => 4], + ], + 'settings' => [ + 'waves' => 3, + 'origin' => ['x' => 6, 'y' => 7], + ], + ], + '__editorFieldTypes' => [ + 'speed' => 'int', + 'waypoints' => 'array', + 'settings' => 'Sendama\\Game\\Scripts\\CompoundSettings', + ], + ], + ], + ], + ]); + + expect($panel->content)->toContain('▼ SchemaProbe') + ->toContain(' Speed: [#####-------] 4') + ->toContain(' ▼ Waypoints') + ->toContain(' Item 1:') + ->toContain(' X: 1') + ->toContain(' Y: 2') + ->toContain(' Item 2:') + ->toContain(' ▼ Settings') + ->toContain(' Waves: 3') + ->toContain(' Origin:') + ->toContain(' X: 6') + ->toContain(' Y: 7'); +}); + +test('inspector panel commits slider edits using the default range step', function () { + $workspace = createInspectorCompoundComponentWorkspace(); + $panel = new InspectorPanel(width: 64, height: 32, workingDirectory: $workspace); + $panel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + [ + 'class' => 'Sendama\\Game\\Scripts\\SchemaProbe', + 'data' => [ + 'speed' => 4, + 'waypoints' => [], + 'settings' => [ + 'waves' => 3, + 'origin' => ['x' => 6, 'y' => 7], + ], + ], + '__editorFieldTypes' => [ + 'speed' => 'int', + 'waypoints' => 'array', + 'settings' => 'Sendama\\Game\\Scripts\\CompoundSettings', + ], + ], + ], + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'Speed'); + + setInspectorInput("\n"); + $panel->update(); + + setInspectorInput("\033[C"); + $panel->update(); + + setInspectorInput("\n"); + $panel->update(); + + $mutation = $panel->consumeHierarchyMutation(); + + expect($mutation['value']['components'][0]['data']['speed'] ?? null)->toBe(5); +}); + +test('inspector panel does not adjust slider edits with up and down keys', function () { + $workspace = createInspectorCompoundComponentWorkspace(); + $panel = new InspectorPanel(width: 64, height: 32, workingDirectory: $workspace); + $panel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + [ + 'class' => 'Sendama\\Game\\Scripts\\SchemaProbe', + 'data' => [ + 'speed' => 4, + 'waypoints' => [], + 'settings' => [ + 'waves' => 3, + 'origin' => ['x' => 6, 'y' => 7], + ], + ], + '__editorFieldTypes' => [ + 'speed' => 'int', + 'waypoints' => 'array', + 'settings' => 'Sendama\\Game\\Scripts\\CompoundSettings', + ], + ], + ], + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'Speed'); + + setInspectorInput("\n"); + $panel->update(); + + setInspectorInput("\033[A"); + $panel->update(); + + setInspectorInput("\n"); + $panel->update(); + + $mutation = $panel->consumeHierarchyMutation(); + + expect($mutation['value']['components'][0]['data']['speed'] ?? null)->toBe(4); +}); + test('inspector panel separates prefab file renames from prefab metadata edits', function () { $panel = new InspectorPanel(width: 48, height: 24); $panel->inspectTarget([ diff --git a/tests/Unit/MainPanelTest.php b/tests/Unit/MainPanelTest.php index 0cffb6f..9448a57 100644 --- a/tests/Unit/MainPanelTest.php +++ b/tests/Unit/MainPanelTest.php @@ -102,11 +102,11 @@ function setMainPanelMouseEvent(?MouseEvent $event): void $focusBorderColor = new ReflectionProperty(Widget::class, 'focusBorderColor'); $focusBorderColor->setAccessible(true); - expect($focusBorderColor->getValue($panel))->toBe(Color::LIGHT_CYAN); + expect($focusBorderColor->getValue($panel))->toBe(Color::LIGHT_RED); $panel->setPlayModeActive(true); - expect($focusBorderColor->getValue($panel))->toBe(Color::BROWN); + expect($focusBorderColor->getValue($panel))->toBe(Color::LIGHT_GREEN); }); test('main panel highlights the active tab in the divider', function () { @@ -751,7 +751,7 @@ function setMainPanelMouseEvent(?MouseEvent $event): void $focusedLine = '|' . str_pad($panel->content[2], $panelWidth - 2) . '|'; $focusedRenderedLine = $decorateSceneLine->invoke($panel, $focusedLine, null, 2); - expect($focusedRenderedLine)->toContain("\033[5;30;46m"); + expect($focusedRenderedLine)->toContain("\033[5;30;101m"); $hasFocus->setValue($panel, false); $refreshContent->invoke($panel); @@ -759,8 +759,8 @@ function setMainPanelMouseEvent(?MouseEvent $event): void $blurredLine = '|' . str_pad($panel->content[2], $panelWidth - 2) . '|'; $blurredRenderedLine = $decorateSceneLine->invoke($panel, $blurredLine, null, 2); - expect($blurredRenderedLine)->not->toContain("\033[5;30;46m"); - expect($blurredRenderedLine)->not->toContain("\033[30;46m"); + expect($blurredRenderedLine)->not->toContain("\033[5;30;101m"); + expect($blurredRenderedLine)->not->toContain("\033[30;101m"); }); test('main panel projects scene labels using the engine display coordinates', function () { diff --git a/tests/Unit/PrefabLoaderTest.php b/tests/Unit/PrefabLoaderTest.php index 99ea714..c7aacb6 100644 --- a/tests/Unit/PrefabLoaderTest.php +++ b/tests/Unit/PrefabLoaderTest.php @@ -111,3 +111,289 @@ class Bullet extends \Sendama\Engine\Core\Component 'maxBound' => 'Sendama\\Engine\\Core\\Vector2|null', ]); }); + +test('prefab loader preserves native engine object defaults for typed component fields', function () { + $workspace = sys_get_temp_dir() . '/sendama-prefab-loader-native-fields-' . uniqid(); + mkdir($workspace . '/assets/Prefabs', 0777, true); + mkdir($workspace . '/vendor', 0777, true); + + file_put_contents( + $workspace . '/vendor/autoload.php', + <<<'PHP' +x; + } + + public function getY(): int + { + return $this->y; + } + } + + class Texture + { + public function __construct(public string $path) + { + } + } + + class Sprite + { + public function __construct( + public Texture $texture, + public array $rect, + public array $pivot = ['x' => 0, 'y' => 0], + ) { + } + } + + class GameObject + { + public function __construct( + private string $name, + private ?string $tag = null, + private Vector2 $position = new Vector2(), + private Vector2 $rotation = new Vector2(), + private Vector2 $scale = new Vector2(1, 1), + private ?object $sprite = null, + ) { + } + } + + abstract class Component + { + public function __construct(private readonly GameObject $gameObject) + { + } + } +} + +namespace Sendama\Game\Scripts { + use Sendama\Engine\Core\Behaviours\Attributes\SerializeField; + use Sendama\Engine\Core\Component; + use Sendama\Engine\Core\GameObject; + use Sendama\Engine\Core\Sprite; + use Sendama\Engine\Core\Texture; + + class WeaponConfig extends Component + { + #[SerializeField] + protected ?Texture $bulletTexture = null; + + #[SerializeField] + protected ?Sprite $aimSprite = null; + + public function __construct(GameObject $gameObject) + { + parent::__construct($gameObject); + $this->bulletTexture = new Texture('Textures/bullet.texture'); + $this->aimSprite = new Sprite( + new Texture('Textures/bullet.texture'), + ['x' => 1, 'y' => 2, 'width' => 3, 'height' => 4], + ['x' => 0, 'y' => 1], + ); + } + } +} +PHP + ); + + $prefabPath = $workspace . '/assets/Prefabs/weapon.prefab.php'; + + file_put_contents( + $prefabPath, + <<<'PHP' + \Sendama\Engine\Core\GameObject::class, + 'name' => 'Weapon', + 'tag' => 'Weapon', + 'position' => ['x' => 0, 'y' => 0], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + [ + 'class' => \Sendama\Game\Scripts\WeaponConfig::class, + 'data' => [], + ], + ], +]; +PHP + ); + + $loader = new PrefabLoader($workspace); + $prefab = $loader->load($prefabPath); + + expect($prefab)->not->toBeNull() + ->and($prefab['components'][0]['data'])->toBe([ + 'bulletTexture' => 'Textures/bullet.texture', + 'aimSprite' => [ + 'texture' => 'Textures/bullet.texture', + 'rect' => [ + 'x' => 1, + 'y' => 2, + 'width' => 3, + 'height' => 4, + ], + 'pivot' => [ + 'x' => 0, + 'y' => 1, + ], + ], + ]) + ->and($prefab['components'][0]['__editorFieldTypes'] ?? null)->toBe([ + 'bulletTexture' => 'Sendama\\Engine\\Core\\Texture|null', + 'aimSprite' => 'Sendama\\Engine\\Core\\Sprite|null', + ]); +}); + +test('prefab loader preserves compound structure defaults for typed component fields', function () { + $workspace = sys_get_temp_dir() . '/sendama-prefab-loader-compound-fields-' . uniqid(); + mkdir($workspace . '/assets/Prefabs', 0777, true); + mkdir($workspace . '/vendor', 0777, true); + + file_put_contents( + $workspace . '/vendor/autoload.php', + <<<'PHP' +x; + } + + public function getY(): int + { + return $this->y; + } + } + + class GameObject + { + public function __construct( + private string $name, + private ?string $tag = null, + private Vector2 $position = new Vector2(), + private Vector2 $rotation = new Vector2(), + private Vector2 $scale = new Vector2(1, 1), + private ?object $sprite = null, + ) { + } + } + + abstract class Component + { + public function __construct(private readonly GameObject $gameObject) + { + } + } +} + +namespace Sendama\Game\Scripts { + use Sendama\Engine\Core\Component; + use Sendama\Engine\Core\GameObject; + use Sendama\Engine\Core\Vector2; + + class CompoundSettings + { + public int $waves = 3; + public Vector2 $origin; + + public function __construct() + { + $this->origin = new Vector2(6, 7); + } + } + + class SchemaProbe extends Component + { + public int $speed = 4; + + /** @var Vector2[] */ + public array $waypoints = []; + + public CompoundSettings $settings; + + public function __construct(GameObject $gameObject) + { + parent::__construct($gameObject); + $this->waypoints = [ + new Vector2(1, 2), + new Vector2(3, 4), + ]; + $this->settings = new CompoundSettings(); + } + } +} +PHP + ); + + $prefabPath = $workspace . '/assets/Prefabs/schema.prefab.php'; + + file_put_contents( + $prefabPath, + <<<'PHP' + \Sendama\Engine\Core\GameObject::class, + 'name' => 'Schema Owner', + 'tag' => 'Manager', + 'position' => ['x' => 0, 'y' => 0], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + [ + 'class' => \Sendama\Game\Scripts\SchemaProbe::class, + ], + ], +]; +PHP + ); + + $loader = new PrefabLoader($workspace); + $prefab = $loader->load($prefabPath); + + expect($prefab)->not->toBeNull() + ->and($prefab['components'][0]['data'] ?? null)->toBe([ + 'speed' => 4, + 'waypoints' => [ + ['x' => 1, 'y' => 2], + ['x' => 3, 'y' => 4], + ], + 'settings' => [ + 'waves' => 3, + 'origin' => ['x' => 6, 'y' => 7], + ], + ]) + ->and($prefab['components'][0]['__editorFieldTypes'] ?? null)->toBe([ + 'speed' => 'int', + 'waypoints' => 'array', + 'settings' => 'Sendama\\Game\\Scripts\\CompoundSettings', + ]); +}); diff --git a/tests/Unit/ProjectAutoloadLoaderTest.php b/tests/Unit/ProjectAutoloadLoaderTest.php new file mode 100644 index 0000000..c1b3881 --- /dev/null +++ b/tests/Unit/ProjectAutoloadLoaderTest.php @@ -0,0 +1,32 @@ +toBeTrue() + ->and(defined('DEFAULT_DIALOG_HEIGHT'))->toBeTrue(); + + $autoloadPath = tempnam(sys_get_temp_dir(), 'sendama-autoload-'); + + expect($autoloadPath)->not->toBeFalse(); + + $fixtureClassName = 'ProjectAutoloadLoaderFixture' . bin2hex(random_bytes(4)); + + file_put_contents( + $autoloadPath, + "toBeTrue(); +}); diff --git a/tests/Unit/SceneLoaderTest.php b/tests/Unit/SceneLoaderTest.php index e48db58..7a7b69f 100644 --- a/tests/Unit/SceneLoaderTest.php +++ b/tests/Unit/SceneLoaderTest.php @@ -643,6 +643,305 @@ class Gun extends Behaviour ]); }); +test('scene loader preserves native engine object defaults for typed component fields', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-loader-native-fields-' . uniqid(); + mkdir($workspace . '/assets/Scenes', 0777, true); + mkdir($workspace . '/vendor', 0777, true); + + file_put_contents( + $workspace . '/vendor/autoload.php', + <<<'PHP' +x; + } + + public function getY(): int + { + return $this->y; + } + } + + class Texture + { + public function __construct(public string $path) + { + } + } + + class Sprite + { + public function __construct( + public Texture $texture, + public array $rect, + public array $pivot = ['x' => 0, 'y' => 0], + ) { + } + } + + class GameObject + { + public function __construct( + private string $name, + private ?string $tag = null, + private Vector2 $position = new Vector2(), + private Vector2 $rotation = new Vector2(), + private Vector2 $scale = new Vector2(1, 1), + private ?object $sprite = null, + ) { + } + } + + abstract class Component + { + public function __construct(private readonly GameObject $gameObject) + { + } + } +} + +namespace Sendama\Game { + use Sendama\Engine\Core\Behaviours\Attributes\SerializeField; + use Sendama\Engine\Core\Component; + use Sendama\Engine\Core\GameObject; + use Sendama\Engine\Core\Sprite; + use Sendama\Engine\Core\Texture; + + class WeaponConfig extends Component + { + #[SerializeField] + protected ?Texture $bulletTexture = null; + + #[SerializeField] + protected ?Sprite $aimSprite = null; + + public function __construct(GameObject $gameObject) + { + parent::__construct($gameObject); + $this->bulletTexture = new Texture('Textures/bullet.texture'); + $this->aimSprite = new Sprite( + new Texture('Textures/bullet.texture'), + ['x' => 1, 'y' => 2, 'width' => 3, 'height' => 4], + ['x' => 0, 'y' => 1], + ); + } + } +} +PHP + ); + + file_put_contents( + $workspace . '/assets/Scenes/level01.scene.php', + <<<'PHP' + [ + [ + 'type' => GameObject::class, + 'name' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + [ + 'class' => 'Sendama\\Game\\WeaponConfig', + 'data' => [], + ], + ], + ], + ], +]; +PHP + ); + + $loader = new SceneLoader($workspace); + $scene = $loader->load(new EditorSceneSettings(active: 0, loaded: ['level01'])); + + expect($scene)->not->toBeNull(); + expect($scene->hierarchy[0]['components'])->toBe([ + [ + 'class' => 'Sendama\\Game\\WeaponConfig', + 'data' => [ + 'bulletTexture' => 'Textures/bullet.texture', + 'aimSprite' => [ + 'texture' => 'Textures/bullet.texture', + 'rect' => [ + 'x' => 1, + 'y' => 2, + 'width' => 3, + 'height' => 4, + ], + 'pivot' => [ + 'x' => 0, + 'y' => 1, + ], + ], + ], + '__editorFieldTypes' => [ + 'bulletTexture' => 'Sendama\\Engine\\Core\\Texture|null', + 'aimSprite' => 'Sendama\\Engine\\Core\\Sprite|null', + ], + ], + ]); +}); + +test('scene loader preserves compound structure defaults for typed component fields', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-loader-compound-fields-' . uniqid(); + mkdir($workspace . '/assets/Scenes', 0777, true); + mkdir($workspace . '/vendor', 0777, true); + + file_put_contents( + $workspace . '/vendor/autoload.php', + <<<'PHP' +x; + } + + public function getY(): int + { + return $this->y; + } + } + + class GameObject + { + public function __construct( + private string $name, + private ?string $tag = null, + private Vector2 $position = new Vector2(), + private Vector2 $rotation = new Vector2(), + private Vector2 $scale = new Vector2(1, 1), + private ?object $sprite = null, + ) { + } + } + + abstract class Component + { + public function __construct(private readonly GameObject $gameObject) + { + } + } +} + +namespace Sendama\Game\Scripts { + use Sendama\Engine\Core\Component; + use Sendama\Engine\Core\GameObject; + use Sendama\Engine\Core\Vector2; + + class CompoundSettings + { + public int $waves = 3; + public Vector2 $origin; + + public function __construct() + { + $this->origin = new Vector2(6, 7); + } + } + + class SchemaProbe extends Component + { + public int $speed = 4; + + /** @var Vector2[] */ + public array $waypoints = []; + + public CompoundSettings $settings; + + public function __construct(GameObject $gameObject) + { + parent::__construct($gameObject); + $this->waypoints = [ + new Vector2(1, 2), + new Vector2(3, 4), + ]; + $this->settings = new CompoundSettings(); + } + } +} +PHP + ); + + file_put_contents( + $workspace . '/assets/Scenes/level01.scene.php', + <<<'PHP' + 'level01', + 'width' => 80, + 'height' => 25, + 'hierarchy' => [ + [ + 'type' => \Sendama\Engine\Core\GameObject::class, + 'name' => 'Controller', + 'tag' => 'Manager', + 'position' => ['x' => 0, 'y' => 0], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + [ + 'class' => \Sendama\Game\Scripts\SchemaProbe::class, + ], + ], + ], + ], +]; +PHP + ); + + $loader = new SceneLoader($workspace); + $scene = $loader->load(new EditorSceneSettings(active: 0, loaded: ['level01'])); + + expect($scene)->not->toBeNull() + ->and($scene->hierarchy[0]['components'][0]['data'] ?? null)->toBe([ + 'speed' => 4, + 'waypoints' => [ + ['x' => 1, 'y' => 2], + ['x' => 3, 'y' => 4], + ], + 'settings' => [ + 'waves' => 3, + 'origin' => ['x' => 6, 'y' => 7], + ], + ]) + ->and($scene->hierarchy[0]['components'][0]['__editorFieldTypes'] ?? null)->toBe([ + 'speed' => 'int', + 'waypoints' => 'array', + 'settings' => 'Sendama\\Game\\Scripts\\CompoundSettings', + ]); +}); + test('scene loader annotates GameObject component fields for prefab assignment', function () { $workspace = sys_get_temp_dir() . '/sendama-scene-loader-prefab-field-' . uniqid(); mkdir($workspace . '/Assets/Scenes', 0777, true); diff --git a/tests/Unit/SliderInputControlTest.php b/tests/Unit/SliderInputControlTest.php new file mode 100644 index 0000000..736459f --- /dev/null +++ b/tests/Unit/SliderInputControlTest.php @@ -0,0 +1,13 @@ +setAvailableWidth(28); + + expect($control->renderLines())->toBe([ + ' Max Spawn Distance: 100', + ' [############]', + ]); +}); diff --git a/tests/Unit/SnackbarTest.php b/tests/Unit/SnackbarTest.php index a8e5226..1585edc 100644 --- a/tests/Unit/SnackbarTest.php +++ b/tests/Unit/SnackbarTest.php @@ -8,6 +8,7 @@ $snackbar->enqueue('Saved scene level01.scene.php', 'success', 0.5); expect($snackbar->hasActiveNotice())->toBeTrue(); + expect($snackbar->y)->toBe(-2); $initialY = $snackbar->y; $initialX = $snackbar->x; @@ -58,6 +59,24 @@ ->and($output)->toContain("\033[30;41m"); }); +test('snackbar renders partially while sliding into view', function () { + $snackbar = new Snackbar(); + $snackbar->syncLayout(80, 24); + $snackbar->enqueue('Saved scene level01.scene.php', 'success', 1.0); + + $snackbar->update(); + $snackbar->update(); + + expect($snackbar->y)->toBe(0); + + ob_start(); + $snackbar->renderAt(); + $output = ob_get_clean(); + + expect($output)->toContain('Saved scene level01.scene.php') + ->toContain("\033[30;42m"); +}); + test('snackbar does not render while fully off-screen above the viewport', function () { $snackbar = new Snackbar(); $snackbar->syncLayout(80, 24); diff --git a/tests/Unit/UpdateCommandTest.php b/tests/Unit/UpdateCommandTest.php new file mode 100644 index 0000000..e45c44a --- /dev/null +++ b/tests/Unit/UpdateCommandTest.php @@ -0,0 +1,83 @@ + 'sendama/test-game', + 'require' => [ + 'sendamaphp/engine' => '*', + ], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + file_put_contents($projectDirectory . '/sendama.json', json_encode([ + 'name' => 'Test Game', + 'main' => 'game.php', + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); +} + +test('update command runs composer update inside the target project directory', function () { + $projectDirectory = sys_get_temp_dir() . '/sendama-update-' . uniqid(); + createValidUpdateProject($projectDirectory); + + $command = new class extends Update { + public ?string $capturedDirectory = null; + + protected function runComposerUpdate(string $directory): int + { + $this->capturedDirectory = $directory; + echo "composer update {$directory}\n"; + + return Command::SUCCESS; + } + }; + + $input = new ArrayInput([ + '--directory' => $projectDirectory, + ]); + $output = new BufferedOutput(); + + ob_start(); + $exitCode = $command->run($input, $output); + $passthruOutput = ob_get_clean(); + + expect($exitCode)->toBe(0) + ->and($command->capturedDirectory)->toBe($projectDirectory) + ->and($output->fetch())->toContain('Updating the game...') + ->toContain('Update completed.') + ->and($passthruOutput)->toContain("composer update {$projectDirectory}"); +}); + +test('update command returns failure when composer update fails', function () { + $projectDirectory = sys_get_temp_dir() . '/sendama-update-fail-' . uniqid(); + createValidUpdateProject($projectDirectory); + + $command = new class extends Update { + protected function runComposerUpdate(string $directory): int + { + echo "composer update failed for {$directory}\n"; + + return 1; + } + }; + + $input = new ArrayInput([ + '--directory' => $projectDirectory, + ]); + $output = new BufferedOutput(); + + ob_start(); + $exitCode = $command->run($input, $output); + $passthruOutput = ob_get_clean(); + + expect($exitCode)->toBe(1) + ->and($output->fetch())->toContain('Updating the game...') + ->toContain('Update failed.') + ->and($passthruOutput)->toContain("composer update failed for {$projectDirectory}"); +}); diff --git a/tests/Unit/ViewLogCommandTest.php b/tests/Unit/ViewLogCommandTest.php new file mode 100644 index 0000000..19ddf83 --- /dev/null +++ b/tests/Unit/ViewLogCommandTest.php @@ -0,0 +1,154 @@ + 'sendama/test-game', + 'require' => [ + 'sendamaphp/engine' => '*', + ], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + file_put_contents($projectDirectory . '/sendama.json', json_encode([ + 'name' => 'Test Game', + 'main' => 'game.php', + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + file_put_contents($projectDirectory . '/logs/debug.log', "debug line\n"); + file_put_contents($projectDirectory . '/logs/error.log', "error line\n"); +} + +test('view log command resolves a relative project directory and opens the requested log file', function () { + $parentDirectory = sys_get_temp_dir() . '/sendama-view-log-parent-' . uniqid(); + $projectDirectory = $parentDirectory . '/blasters'; + createValidLogProject($projectDirectory); + + $command = new class extends ViewLog { + public array $capturedLogFiles = []; + public ?string $capturedType = null; + + protected function runLogViewer(array $logFiles, LogOption $type): int + { + $this->capturedLogFiles = $logFiles; + $this->capturedType = $type->value; + + return Command::SUCCESS; + } + }; + + $input = new ArrayInput([ + 'type' => 'debug', + '--directory' => 'blasters', + ]); + $output = new BufferedOutput(); + $originalWorkingDirectory = getcwd(); + + try { + chdir($parentDirectory); + $exitCode = $command->run($input, $output); + } finally { + if ($originalWorkingDirectory !== false) { + chdir($originalWorkingDirectory); + } + } + + expect($exitCode)->toBe(0) + ->and($command->capturedType)->toBe('debug') + ->and($command->capturedLogFiles)->toBe([ + $projectDirectory . '/logs/debug.log', + ]); +}); + +test('view log command opens both debug and error logs when all is requested', function () { + $projectDirectory = sys_get_temp_dir() . '/sendama-view-log-all-' . uniqid(); + createValidLogProject($projectDirectory); + + $command = new class extends ViewLog { + public array $capturedLogFiles = []; + + protected function runLogViewer(array $logFiles, LogOption $type): int + { + $this->capturedLogFiles = $logFiles; + + return Command::SUCCESS; + } + }; + + $input = new ArrayInput([ + 'type' => 'all', + '--directory' => $projectDirectory, + ]); + $output = new BufferedOutput(); + + $exitCode = $command->run($input, $output); + + expect($exitCode)->toBe(0) + ->and($command->capturedLogFiles)->toBe([ + $projectDirectory . '/logs/debug.log', + $projectDirectory . '/logs/error.log', + ]); +}); + +test('view log command rejects invalid log types', function () { + $projectDirectory = sys_get_temp_dir() . '/sendama-view-log-invalid-' . uniqid(); + createValidLogProject($projectDirectory); + + $command = new ViewLog(); + $input = new ArrayInput([ + 'type' => 'trace', + '--directory' => $projectDirectory, + ]); + $output = new BufferedOutput(); + + $exitCode = $command->run($input, $output); + + expect($exitCode)->toBe(1) + ->and($output->fetch())->toContain('Invalid log type.'); +}); + +test('view log command fails when the requested log file is missing', function () { + $projectDirectory = sys_get_temp_dir() . '/sendama-view-log-missing-' . uniqid(); + createValidLogProject($projectDirectory); + unlink($projectDirectory . '/logs/error.log'); + + $command = new ViewLog(); + $input = new ArrayInput([ + 'type' => 'error', + '--directory' => $projectDirectory, + ]); + $output = new BufferedOutput(); + + $exitCode = $command->run($input, $output); + + expect($exitCode)->toBe(1) + ->and($output->fetch())->toContain('Log file ' . $projectDirectory . '/logs/error.log not found.'); +}); + +test('view log command prefers multitail for all logs when available', function () { + $command = new class extends ViewLog { + protected function hasMultitail(): bool + { + return true; + } + + public function exposeBuildLogViewerCommand(array $logFiles, LogOption $type): string + { + return $this->buildLogViewerCommand($logFiles, $type); + } + }; + + $commandLine = $command->exposeBuildLogViewerCommand([ + '/tmp/debug.log', + '/tmp/error.log', + ], LogOption::ALL); + + expect($commandLine)->toBe("multitail '/tmp/debug.log' '/tmp/error.log'"); +});