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'");
+});