From a87cb8dcad5f376444d14f67298f38b693b6a8f8 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 12 Mar 2026 12:46:09 +0200 Subject: [PATCH] feat(console): implement tabbed log display with auto-refresh and manual refresh functionality --- docs/Editor.md | 26 +- src/Editor/Editor.php | 2 + src/Editor/EditorSettings.php | 34 +- src/Editor/IO/Enumerations/KeyCode.php | 1 + src/Editor/IO/InputManager.php | 48 +- src/Editor/SceneLoader.php | 301 ++++++++++++- src/Editor/SceneSourceParser.php | 21 +- src/Editor/SceneWriter.php | 97 ++++- src/Editor/Widgets/ConsolePanel.php | 388 ++++++++++++++++- src/Editor/Widgets/Controls/InputControl.php | 5 + .../Widgets/Controls/SectionControl.php | 44 ++ src/Editor/Widgets/InspectorPanel.php | 243 +++++++++-- src/Editor/Widgets/MainPanel.php | 50 ++- tests/Unit/ConsolePanelTest.php | 248 ++++++++++- tests/Unit/EditorSettingsTest.php | 66 +++ tests/Unit/InputManagerTest.php | 26 ++ tests/Unit/InspectorPanelTest.php | 127 ++++++ tests/Unit/MainPanelTest.php | 90 ++++ tests/Unit/SceneLoaderTest.php | 409 ++++++++++++++++++ tests/Unit/SceneWriterTest.php | 228 ++++++++++ 20 files changed, 2360 insertions(+), 94 deletions(-) create mode 100644 src/Editor/Widgets/Controls/SectionControl.php create mode 100644 tests/Unit/EditorSettingsTest.php diff --git a/docs/Editor.md b/docs/Editor.md index 35c9294..2cb0622 100644 --- a/docs/Editor.md +++ b/docs/Editor.md @@ -105,6 +105,7 @@ When the main panel has focus and the `Scene` tab is active, it uses scene-view #### Select Mode Use Select Mode to move between visible scene objects without changing them. +If the selected object has no renderable sprite or text, Scene View shows a muted `x` at its transform position. Controls: @@ -324,6 +325,7 @@ Controls: - `Up` / `Down`: move between controls - `Enter`: activate the selected control +- `/`: toggle the focused collapsible section, such as `Transform`, `Renderer`, or a component block #### 2. Property Selection @@ -434,18 +436,28 @@ The Console panel currently reads from: ```text /logs/debug.log +/logs/error.log ``` Current behavior: -- on editor startup it loads the last three log lines +- it has two tabs: + - `Debug`: reads from `logs/debug.log` if it exists + - `Error`: reads from `logs/error.log` if it exists +- on editor startup each tab loads the last three lines from its own log file - log display is clipped to the console viewport +- it only reads a tab's log file if that file exists +- it auto-refreshes the console tabs from disk every `editor.console.refreshInterval` seconds while the editor is in Play Mode - when the console has focus and the editor is not in play mode, it supports scrolling +- if no refresh interval is configured, the editor uses a default of `5` seconds Controls: +- `Tab`: switch to the next console tab +- `Shift+Tab`: switch to the previous console tab - `Up`: scroll up through older log lines - `Down`: scroll down through newer log lines +- `Shift+R`: manually refresh the active log tab from disk and jump to the newest visible lines The scroll stops: @@ -459,6 +471,18 @@ Current tag colors: - `[INFO]`: blue - `[DEBUG]`: light gray +Configuration: + +```json +{ + "editor": { + "console": { + "refreshInterval": 5 + } + } +} +``` + ## Saving Press `Ctrl+S` to save the loaded scene. diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php index 6821987..9626687 100644 --- a/src/Editor/Editor.php +++ b/src/Editor/Editor.php @@ -602,6 +602,8 @@ private function initializeWidgets(): void ); $this->consolePanel = new ConsolePanel( logFilePath: Path::join($this->workingDirectory, 'logs', 'debug.log'), + errorLogFilePath: Path::join($this->workingDirectory, 'logs', 'error.log'), + refreshIntervalSeconds: $this->settings->consoleRefreshIntervalSeconds, ); $this->inspectorPanel = new InspectorPanel( workingDirectory: $this->workingDirectory, diff --git a/src/Editor/EditorSettings.php b/src/Editor/EditorSettings.php index da56dbd..dcad5f5 100644 --- a/src/Editor/EditorSettings.php +++ b/src/Editor/EditorSettings.php @@ -7,6 +7,8 @@ class EditorSettings { + public const float DEFAULT_CONSOLE_REFRESH_INTERVAL_SECONDS = 5.0; + protected(set) int $width { get { return $this->width; @@ -23,7 +25,8 @@ class EditorSettings * @param EditorSceneSettings $scenes */ public function __construct( - public readonly EditorSceneSettings $scenes + public readonly EditorSceneSettings $scenes, + public readonly float $consoleRefreshIntervalSeconds = self::DEFAULT_CONSOLE_REFRESH_INTERVAL_SECONDS, ) { $terminalSize = get_max_terminal_size(); @@ -61,6 +64,31 @@ public static function loadFromDirectory(string $workingDirectory): self */ public static function fromArray(array $data): self { - return new self(scenes: EditorSceneSettings::fromArray($data["scenes"] ?? [])); + $editorData = is_array($data['editor'] ?? null) ? $data['editor'] : $data; + $scenesData = $editorData['scenes'] ?? $data['scenes'] ?? []; + $consoleData = is_array($editorData['console'] ?? null) ? $editorData['console'] : []; + $refreshInterval = $consoleData['refreshInterval'] + ?? $editorData['consoleRefreshInterval'] + ?? self::DEFAULT_CONSOLE_REFRESH_INTERVAL_SECONDS; + + return new self( + scenes: EditorSceneSettings::fromArray(is_array($scenesData) ? $scenesData : []), + consoleRefreshIntervalSeconds: self::normalizeRefreshInterval($refreshInterval), + ); + } + + private static function normalizeRefreshInterval(mixed $refreshInterval): float + { + if (!is_numeric($refreshInterval)) { + return self::DEFAULT_CONSOLE_REFRESH_INTERVAL_SECONDS; + } + + $normalizedRefreshInterval = (float) $refreshInterval; + + if ($normalizedRefreshInterval <= 0) { + return self::DEFAULT_CONSOLE_REFRESH_INTERVAL_SECONDS; + } + + return $normalizedRefreshInterval; } -} \ No newline at end of file +} diff --git a/src/Editor/IO/Enumerations/KeyCode.php b/src/Editor/IO/Enumerations/KeyCode.php index 2bb94cd..9a724f0 100644 --- a/src/Editor/IO/Enumerations/KeyCode.php +++ b/src/Editor/IO/Enumerations/KeyCode.php @@ -20,6 +20,7 @@ enum KeyCode: string case BACKSPACE = 'backspace'; case ESCAPE = 'escape'; case DELETE = 'delete'; + case SLASH = '/'; case CTRL_C = 'ctrl_c'; case CTRL_S = 'ctrl_s'; case CTRL_Y = 'ctrl_y'; diff --git a/src/Editor/IO/InputManager.php b/src/Editor/IO/InputManager.php index bf5c6e9..f9814ae 100644 --- a/src/Editor/IO/InputManager.php +++ b/src/Editor/IO/InputManager.php @@ -14,6 +14,13 @@ class InputManager implements StaticObservableInterface { use StaticObservableTrait; + private const array COALESCED_REPEATABLE_KEYS = [ + KeyCode::UP->value, + KeyCode::RIGHT->value, + KeyCode::DOWN->value, + KeyCode::LEFT->value, + ]; + /** * @var string The current key press. */ @@ -110,10 +117,15 @@ public static function enableEcho(): void public static function handleInput(): void { self::$previousKeyPress = self::$keyPress; - if (self::$inputQueue === []) { - self::$inputQueue = self::tokenizeInput(stream_get_contents(STDIN) ?: ''); + + $incomingTokens = self::tokenizeInput(stream_get_contents(STDIN) ?: ''); + + if ($incomingTokens !== []) { + self::$inputQueue = [...self::$inputQueue, ...$incomingTokens]; } + self::$inputQueue = self::coalesceRepeatableTokens(self::$inputQueue); + self::$keyPress = array_shift(self::$inputQueue) ?? ''; self::$mouseEvent = self::parseMouseEvent(self::$keyPress); @@ -277,7 +289,7 @@ public static function areAllKeysPressed(array $keyCodes): bool */ public static function isKeyPressed(KeyCode $keyCode): bool { - return self::$keyPress === $keyCode->value; + return self::getKey(self::$keyPress) === $keyCode->value; } /** @@ -396,6 +408,36 @@ private static function tokenizeInput(string $input): array return $tokens; } + private static function coalesceRepeatableTokens(array $tokens): array + { + if ($tokens === []) { + return []; + } + + $coalescedTokens = []; + $previousNormalizedToken = null; + $previousWasRepeatable = false; + + foreach ($tokens as $token) { + if (!is_string($token) || $token === '') { + continue; + } + + $normalizedToken = self::getKey($token); + $isRepeatableToken = in_array($normalizedToken, self::COALESCED_REPEATABLE_KEYS, true); + + if ($isRepeatableToken && $previousWasRepeatable && $normalizedToken === $previousNormalizedToken) { + continue; + } + + $coalescedTokens[] = $token; + $previousNormalizedToken = $normalizedToken; + $previousWasRepeatable = $isRepeatableToken; + } + + return $coalescedTokens; + } + private static function extractEscapeSequence(string $input): string { $knownSequences = [ diff --git a/src/Editor/SceneLoader.php b/src/Editor/SceneLoader.php index 7c61444..6768182 100644 --- a/src/Editor/SceneLoader.php +++ b/src/Editor/SceneLoader.php @@ -23,7 +23,9 @@ public function load(EditorSceneSettings $sceneSettings): ?SceneDTO return null; } - $sceneData = $this->loadSceneData($scenePath); + $sceneDataBundle = $this->loadSceneDataBundle($scenePath); + $sceneData = $sceneDataBundle['editor'] ?? []; + $sourceSceneData = $sceneDataBundle['source'] ?? $sceneData; return new SceneDTO( name: basename($scenePath, '.scene.php'), @@ -34,7 +36,7 @@ public function load(EditorSceneSettings $sceneSettings): ?SceneDTO hierarchy: $sceneData['hierarchy'] ?? [], sourcePath: $scenePath, rawData: $sceneData, - sourceData: $sceneData, + sourceData: $sourceSceneData, ); } @@ -149,19 +151,26 @@ private function buildScenePathCandidates(string $configuredScene, ?string $scen return array_values(array_unique($candidates)); } - private function loadSceneData(string $scenePath): array + private function loadSceneDataBundle(string $scenePath): array { - $isolatedSceneData = $this->loadSceneDataInIsolatedProcess($scenePath); - - if (is_array($isolatedSceneData)) { - return $isolatedSceneData; + $isolatedSceneDataBundle = $this->loadSceneDataInIsolatedProcess($scenePath); + + if ( + is_array($isolatedSceneDataBundle) + && is_array($isolatedSceneDataBundle['source'] ?? null) + && is_array($isolatedSceneDataBundle['editor'] ?? null) + ) { + return $isolatedSceneDataBundle; } try { $sceneData = require $scenePath; if (is_array($sceneData)) { - return $sceneData; + return [ + 'source' => $sceneData, + 'editor' => $sceneData, + ]; } Debug::warn("Scene metadata at {$scenePath} did not return an array."); @@ -169,7 +178,12 @@ private function loadSceneData(string $scenePath): array Debug::warn("Failed to load scene metadata at {$scenePath}: {$throwable->getMessage()}"); } - return $this->extractSceneDataFromSource($scenePath); + $fallbackSceneData = $this->extractSceneDataFromSource($scenePath); + + return [ + 'source' => $fallbackSceneData, + 'editor' => $fallbackSceneData, + ]; } private function loadSceneDataInIsolatedProcess(string $scenePath): ?array @@ -179,6 +193,268 @@ private function loadSceneDataInIsolatedProcess(string $scenePath): ?array $autoloadPath = $argv[1] ?? ''; $scenePath = $argv[2] ?? ''; +function normalize_editor_value(mixed $value): mixed +{ + if (is_array($value)) { + $normalized = []; + + foreach ($value as $key => $item) { + $normalized[$key] = normalize_editor_value($item); + } + + return $normalized; + } + + if ($value instanceof UnitEnum) { + return $value instanceof BackedEnum ? $value->value : $value->name; + } + + if (!is_object($value)) { + return $value; + } + + if (method_exists($value, 'getX') && method_exists($value, 'getY')) { + return [ + 'x' => normalize_editor_value($value->getX()), + 'y' => normalize_editor_value($value->getY()), + ]; + } + + if (method_exists($value, 'getName')) { + try { + return $value->getName(); + } catch (Throwable) { + // Ignore and continue. + } + } + + if (method_exists($value, '__serialize')) { + try { + $serializedValue = $value->__serialize(); + + return is_array($serializedValue) + ? normalize_editor_value($serializedValue) + : normalize_editor_value((array) $serializedValue); + } catch (Throwable) { + // Ignore and continue. + } + } + + if ($value instanceof Stringable) { + return (string) $value; + } + + return get_class($value); +} + +function build_vector(mixed $value, array $default = ['x' => 0, 'y' => 0]): ?object +{ + if (!class_exists('\Sendama\Engine\Core\Vector2')) { + return null; + } + + $vectorValue = is_array($value) ? $value : $default; + + return new \Sendama\Engine\Core\Vector2( + (int) ($vectorValue['x'] ?? $default['x']), + (int) ($vectorValue['y'] ?? $default['y']), + ); +} + +function build_sprite(array $item): ?object +{ + $texture = is_array($item['sprite']['texture'] ?? null) ? $item['sprite']['texture'] : null; + + if ( + !is_array($texture) + || !is_string($texture['path'] ?? null) + || $texture['path'] === '' + || !class_exists('\Sendama\Engine\Core\Texture') + || !class_exists('\Sendama\Engine\Core\Sprite') + ) { + return null; + } + + $textureObject = new \Sendama\Engine\Core\Texture($texture['path']); + $rect = [ + 'position' => normalize_editor_value(build_vector($texture['position'] ?? null)), + 'size' => normalize_editor_value(build_vector($texture['size'] ?? ['x' => 1, 'y' => 1], ['x' => 1, 'y' => 1])), + ]; + + return new \Sendama\Engine\Core\Sprite($textureObject, $rect); +} + +function build_dummy_game_object(array $item): ?object +{ + if (!class_exists('\Sendama\Engine\Core\GameObject')) { + return null; + } + + $tag = is_string($item['tag'] ?? null) && $item['tag'] !== 'None' + ? $item['tag'] + : null; + + return new \Sendama\Engine\Core\GameObject( + is_string($item['name'] ?? null) ? $item['name'] : 'GameObject', + $tag, + build_vector($item['position'] ?? null) ?? new \Sendama\Engine\Core\Vector2(), + build_vector($item['rotation'] ?? null) ?? new \Sendama\Engine\Core\Vector2(), + build_vector($item['scale'] ?? ['x' => 1, 'y' => 1], ['x' => 1, 'y' => 1]) ?? new \Sendama\Engine\Core\Vector2(1, 1), + null, + ); +} + +function serialize_component_data(string $componentClass, array $item): ?array +{ + if ( + !class_exists($componentClass) + || !class_exists('\Sendama\Engine\Core\Component') + || !is_a($componentClass, '\Sendama\Engine\Core\Component', true) + ) { + return null; + } + + try { + $gameObject = build_dummy_game_object($item); + + if (!is_object($gameObject)) { + return null; + } + + $component = new $componentClass($gameObject); + + return normalize_editor_value(extract_component_serializable_data($component)); + } catch (Throwable) { + return null; + } +} + +function extract_component_serializable_data(object $component): array +{ + $serializedData = []; + $reflection = new ReflectionObject($component); + + foreach ($reflection->getProperties() as $property) { + $isSerializable = $property->isPublic() + || $property->getAttributes('Sendama\Engine\Core\Behaviours\Attributes\SerializeField') !== []; + + if (!$isSerializable) { + continue; + } + + if (method_exists($property, 'isVirtual') && $property->isVirtual()) { + continue; + } + + try { + $serializedData[$property->getName()] = $property->getValue($component); + } catch (Throwable) { + continue; + } + } + + return $serializedData; +} + +function enrich_component_entry(mixed $component, array $item): mixed +{ + if (!is_array($component)) { + return $component; + } + + $componentClass = $component['class'] ?? null; + $defaultComponentData = is_string($componentClass) && $componentClass !== '' + ? serialize_component_data($componentClass, $item) + : null; + + if (array_key_exists('data', $component)) { + $existingComponentData = is_array($component['data']) + ? normalize_editor_value($component['data']) + : normalize_editor_value((array) $component['data']); + + if (is_array($defaultComponentData)) { + $component['data'] = merge_component_data($defaultComponentData, $existingComponentData); + } else { + $component['data'] = $existingComponentData; + } + + return $component; + } + + if (!is_string($componentClass) || $componentClass === '') { + return $component; + } + + if (is_array($defaultComponentData)) { + $component['data'] = $defaultComponentData; + } + + return $component; +} + +function merge_component_data(array $defaultData, array $existingData): array +{ + if ($existingData === []) { + return $defaultData; + } + + $mergedData = $defaultData; + + foreach ($existingData as $key => $value) { + if ( + array_key_exists($key, $defaultData) + && is_array($defaultData[$key]) + && is_array($value) + && !array_is_list($defaultData[$key]) + && !array_is_list($value) + ) { + $mergedData[$key] = merge_component_data($defaultData[$key], $value); + continue; + } + + $mergedData[$key] = $value; + } + + return $mergedData; +} + +function enrich_hierarchy_item(mixed $item): mixed +{ + if (!is_array($item)) { + return $item; + } + + if (is_array($item['components'] ?? null)) { + $item['components'] = array_values(array_map( + static fn (mixed $component): mixed => enrich_component_entry($component, $item), + $item['components'], + )); + } + + if (is_array($item['children'] ?? null)) { + $item['children'] = array_values(array_map( + static fn (mixed $child): mixed => enrich_hierarchy_item($child), + $item['children'], + )); + } + + return $item; +} + +function enrich_scene_data(array $sceneData): array +{ + if (!is_array($sceneData['hierarchy'] ?? null)) { + return $sceneData; + } + + $sceneData['hierarchy'] = array_values(array_map( + static fn (mixed $item): mixed => enrich_hierarchy_item($item), + $sceneData['hierarchy'], + )); + + return $sceneData; +} + if ($scenePath === '' || !is_file($scenePath)) { fwrite(STDERR, "Scene file not found.\n"); exit(1); @@ -201,7 +477,12 @@ private function loadSceneDataInIsolatedProcess(string $scenePath): ?array exit(2); } -$encodedSceneData = json_encode($sceneData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +$payload = [ + 'source' => $sceneData, + 'editor' => enrich_scene_data($sceneData), +]; + +$encodedSceneData = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if (!is_string($encodedSceneData)) { fwrite(STDERR, "Failed to encode scene metadata.\n"); diff --git a/src/Editor/SceneSourceParser.php b/src/Editor/SceneSourceParser.php index 096a257..c417f43 100644 --- a/src/Editor/SceneSourceParser.php +++ b/src/Editor/SceneSourceParser.php @@ -216,13 +216,28 @@ private function parseValue(array $tokens, int &$index, array $terminators, bool private function findReturnIndex(array $tokens): ?int { + $braceDepth = 0; + $lastTopLevelReturnIndex = null; + foreach ($tokens as $index => $token) { - if (is_array($token) && $token[0] === T_RETURN) { - return $index; + $text = $this->tokenText($token); + + if ($text === '{') { + $braceDepth++; + continue; + } + + if ($text === '}') { + $braceDepth = max(0, $braceDepth - 1); + continue; + } + + if ($braceDepth === 0 && is_array($token) && $token[0] === T_RETURN) { + $lastTopLevelReturnIndex = $index; } } - return null; + return $lastTopLevelReturnIndex; } private function isLongArrayStart(array $tokens, int $index): bool diff --git a/src/Editor/SceneWriter.php b/src/Editor/SceneWriter.php index d98f9ca..0dc9bfc 100644 --- a/src/Editor/SceneWriter.php +++ b/src/Editor/SceneWriter.php @@ -119,18 +119,24 @@ private function renderMergedArray( $lines = []; if ($isList) { + $listItemMappings = $this->buildListItemMappings($currentValue, $originalValue); + foreach (array_keys($currentValue) as $index) { - $itemNode = $sourceNode['items'][$index] ?? null; + $originalIndex = $listItemMappings[$index] ?? null; + $itemNode = is_int($originalIndex) + ? ($sourceNode['items'][$originalIndex] ?? null) + : null; if ( is_array($itemNode) && isset($itemNode['node']) - && array_key_exists($index, $originalValue) + && is_int($originalIndex) + && array_key_exists($originalIndex, $originalValue) ) { $lines[] = $childIndent . $this->renderMergedValue( $currentValue[$index], - $originalValue[$index], + $originalValue[$originalIndex], $itemNode['node'], $depth + 1 ) @@ -194,6 +200,91 @@ private function renderMergedArray( return "[\n" . implode("\n", $lines) . "\n" . $indent . "]"; } + private function buildListItemMappings(array $currentValue, array $originalValue): array + { + $mappings = []; + $availableOriginalIndexes = array_keys($originalValue); + + foreach ($currentValue as $currentIndex => $currentItem) { + foreach ($availableOriginalIndexes as $availablePosition => $originalIndex) { + if ($currentItem !== $originalValue[$originalIndex]) { + continue; + } + + $mappings[$currentIndex] = $originalIndex; + unset($availableOriginalIndexes[$availablePosition]); + continue 2; + } + } + + foreach ($currentValue as $currentIndex => $currentItem) { + if (array_key_exists($currentIndex, $mappings)) { + continue; + } + + $currentIdentity = $this->resolveListItemIdentity($currentItem); + + if ($currentIdentity === null) { + continue; + } + + $matchingOriginalIndexes = []; + + foreach ($availableOriginalIndexes as $originalIndex) { + if ($this->resolveListItemIdentity($originalValue[$originalIndex]) === $currentIdentity) { + $matchingOriginalIndexes[] = $originalIndex; + } + } + + if (count($matchingOriginalIndexes) !== 1) { + continue; + } + + $matchedOriginalIndex = $matchingOriginalIndexes[0]; + $mappings[$currentIndex] = $matchedOriginalIndex; + $availablePosition = array_search($matchedOriginalIndex, $availableOriginalIndexes, true); + + if ($availablePosition !== false) { + unset($availableOriginalIndexes[$availablePosition]); + } + } + + return $mappings; + } + + private function resolveListItemIdentity(mixed $value): ?string + { + if (is_scalar($value) || $value === null) { + return get_debug_type($value) . ':' . var_export($value, true); + } + + if (!is_array($value) || array_is_list($value)) { + return null; + } + + $identity = []; + + foreach (['type', 'class', 'name', 'path', 'relativePath', 'environmentTileMapPath', 'text', 'tag'] as $key) { + if (!array_key_exists($key, $value)) { + continue; + } + + $identityValue = $value[$key]; + + if (!is_scalar($identityValue) && $identityValue !== null) { + continue; + } + + $identity[$key] = $identityValue; + } + + if ($identity === []) { + return null; + } + + return json_encode($identity, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: null; + } + private function renderArrayKeyPrefix(int|string $key, ?string $keySource): string { if (is_string($keySource) && trim($keySource) !== '') { diff --git a/src/Editor/Widgets/ConsolePanel.php b/src/Editor/Widgets/ConsolePanel.php index 4569bfa..4cca5c5 100644 --- a/src/Editor/Widgets/ConsolePanel.php +++ b/src/Editor/Widgets/ConsolePanel.php @@ -9,32 +9,92 @@ class ConsolePanel extends Widget { private const int INITIAL_TAIL_LINE_COUNT = 3; - + private const float DEFAULT_REFRESH_INTERVAL_SECONDS = 5.0; + private const string DIVIDER_LINE_CHARACTER = '─'; + private const string TAB_DIVIDER_LINE_CHARACTER = '■'; + private const array TAB_TITLES = ['Debug', 'Error']; + + protected array $logMessagesByTab = [ + 'Debug' => [], + 'Error' => [], + ]; + protected array $sessionMessagesByTab = [ + 'Debug' => [], + 'Error' => [], + ]; protected array $messages = []; + protected array $scrollOffsetsByTab = [ + 'Debug' => 0, + 'Error' => 0, + ]; protected int $scrollOffset = 0; protected bool $isPlayModeActive = false; + protected float $refreshIntervalSeconds; + protected float $lastLogRefreshAt; + protected int $activeTabIndex = 0; + protected int $activeTabOffset = 0; + protected int $activeTabLength = 0; + protected Color $activeIndicatorColor = Color::LIGHT_CYAN; public function __construct( array $position = ['x' => 37, 'y' => 22], int $width = 96, int $height = 8, - protected ?string $logFilePath = null + protected ?string $logFilePath = null, + protected ?string $errorLogFilePath = null, + float $refreshIntervalSeconds = self::DEFAULT_REFRESH_INTERVAL_SECONDS, ) { parent::__construct('Console', '', $position, $width, $height); + $this->refreshIntervalSeconds = $refreshIntervalSeconds > 0 + ? $refreshIntervalSeconds + : self::DEFAULT_REFRESH_INTERVAL_SECONDS; + $this->lastLogRefreshAt = microtime(true); $this->loadInitialLogTail(); - $this->update(); + $this->refreshVisibleContent(); + } + + public function getActiveTab(): string + { + return self::TAB_TITLES[$this->activeTabIndex]; + } + + public function cycleFocusForward(): bool + { + $this->activateNextTab(); + + return true; + } + + public function cycleFocusBackward(): bool + { + $this->activatePreviousTab(); + + return true; } public function append(string $message): void { - $this->messages[] = $message; + $tabTitle = $this->resolveSessionTabTitle($message); + $this->sessionMessagesByTab[$tabTitle][] = $message; + + if ($tabTitle !== $this->getActiveTab()) { + return; + } + + $this->rebuildMessages(); $this->scrollToRecentLines(); $this->refreshVisibleContent(); } public function clear(): void { + foreach (self::TAB_TITLES as $tabTitle) { + $this->logMessagesByTab[$tabTitle] = []; + $this->sessionMessagesByTab[$tabTitle] = []; + $this->scrollOffsetsByTab[$tabTitle] = 0; + } + $this->messages = []; $this->scrollOffset = 0; $this->refreshVisibleContent(); @@ -42,7 +102,22 @@ public function clear(): void public function setPlayModeActive(bool $isPlayModeActive): void { + if ($this->isPlayModeActive === $isPlayModeActive) { + return; + } + $this->isPlayModeActive = $isPlayModeActive; + + if ($isPlayModeActive) { + $this->refreshAllTabsFromLogFiles(); + } + + $this->refreshVisibleContent(); + } + + public function refreshFromLogFile(): void + { + $this->refreshTabFromLogFile($this->getActiveTab(), true); } public function scrollUp(): void @@ -52,6 +127,7 @@ public function scrollUp(): void } $this->scrollOffset = max(0, $this->scrollOffset - 1); + $this->persistScrollOffset(); $this->refreshVisibleContent(); } @@ -62,12 +138,22 @@ public function scrollDown(): void } $this->scrollOffset = min(count($this->messages) - 1, $this->scrollOffset + 1); + $this->persistScrollOffset(); $this->refreshVisibleContent(); } public function update(): void { + if ($this->shouldRefreshFromLogFile()) { + $this->refreshAllTabsFromLogFiles(); + } + if ($this->hasFocus() && !$this->isPlayModeActive) { + if (Input::isKeyDown(KeyCode::R)) { + $this->refreshFromLogFile(); + return; + } + if (Input::isKeyDown(KeyCode::UP)) { $this->scrollUp(); return; @@ -84,6 +170,14 @@ public function update(): void protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string { + if ($lineIndex === 1) { + return $this->decorateDividerLine($line, $contentColor, $lineIndex); + } + + if ($lineIndex === 0) { + return $this->decorateTabLine($line, $contentColor, $lineIndex); + } + $visibleLine = mb_substr($line, 0, $this->width); $visibleLength = mb_strlen($visibleLine); @@ -101,20 +195,123 @@ protected function decorateContentLine(string $line, ?Color $contentColor, int $ . $this->wrapWithColor($rightBorder, $borderColor); } + private function activateNextTab(): void + { + $previousTabTitle = $this->getActiveTab(); + $this->scrollOffsetsByTab[$previousTabTitle] = $this->scrollOffset; + $this->activeTabIndex = ($this->activeTabIndex + 1) % count(self::TAB_TITLES); + $this->restoreActiveTabState(); + } + + private function activatePreviousTab(): void + { + $previousTabTitle = $this->getActiveTab(); + $this->scrollOffsetsByTab[$previousTabTitle] = $this->scrollOffset; + $this->activeTabIndex = ($this->activeTabIndex - 1 + count(self::TAB_TITLES)) % count(self::TAB_TITLES); + $this->restoreActiveTabState(); + } + + private function restoreActiveTabState(): void + { + $this->scrollOffset = $this->scrollOffsetsByTab[$this->getActiveTab()] ?? 0; + $this->rebuildMessages(); + $this->refreshVisibleContent(); + } + private function loadInitialLogTail(): void { - if ($this->logFilePath === null || !is_file($this->logFilePath)) { - return; + foreach (self::TAB_TITLES as $tabTitle) { + $this->logMessagesByTab[$tabTitle] = $this->loadLogLinesForTab($tabTitle); + $this->rebuildMessagesForTab($tabTitle); + $this->scrollOffsetsByTab[$tabTitle] = $this->resolveRecentScrollOffsetForTab($tabTitle); } - $lines = file($this->logFilePath, FILE_IGNORE_NEW_LINES); + $this->lastLogRefreshAt = microtime(true); + $this->restoreActiveTabState(); + } - if ($lines === false) { - return; + private function refreshAllTabsFromLogFiles(): void + { + foreach (self::TAB_TITLES as $tabTitle) { + $shouldJumpToLatest = $tabTitle === $this->getActiveTab(); + $this->refreshTabFromLogFile($tabTitle, $shouldJumpToLatest); } - $this->messages = $lines; - $this->scrollToRecentLines(); + $this->lastLogRefreshAt = microtime(true); + $this->restoreActiveTabState(); + } + + private function refreshTabFromLogFile(string $tabTitle, bool $jumpToLatestVisibleLines): void + { + $this->logMessagesByTab[$tabTitle] = $this->loadLogLinesForTab($tabTitle); + $this->rebuildMessagesForTab($tabTitle); + + if ($jumpToLatestVisibleLines) { + $this->scrollOffsetsByTab[$tabTitle] = $this->resolveLatestVisibleScrollOffsetForTab($tabTitle); + } else { + $this->scrollOffsetsByTab[$tabTitle] = $this->clampScrollOffsetValue( + $this->scrollOffsetsByTab[$tabTitle] ?? 0, + count($this->messagesForTab($tabTitle)), + ); + } + + if ($tabTitle === $this->getActiveTab()) { + $this->scrollOffset = $this->scrollOffsetsByTab[$tabTitle]; + $this->rebuildMessages(); + $this->refreshVisibleContent(); + } + } + + private function decorateTabLine(string $line, ?Color $contentColor, int $lineIndex): string + { + $visibleLine = mb_substr($line, 0, $this->width); + $visibleLength = mb_strlen($visibleLine); + + if ($visibleLength <= 1) { + return parent::decorateContentLine($line, $contentColor, $lineIndex); + } + + $leftBorder = mb_substr($visibleLine, 0, 1); + $middle = $visibleLength > 2 ? mb_substr($visibleLine, 1, $visibleLength - 2) : ''; + $rightBorder = mb_substr($visibleLine, -1); + $borderColor = $this->hasFocus() ? $this->focusBorderColor : $contentColor; + $indicatorStart = $this->padding->leftPadding + $this->activeTabOffset; + $indicatorLength = $this->activeTabLength; + $beforeIndicator = mb_substr($middle, 0, $indicatorStart); + $indicator = mb_substr($middle, $indicatorStart, $indicatorLength); + $afterIndicator = mb_substr($middle, $indicatorStart + $indicatorLength); + + return $this->wrapWithColor($leftBorder, $borderColor) + . $this->wrapWithColor($beforeIndicator, $contentColor) + . $this->wrapWithColor($indicator, $this->activeIndicatorColor) + . $this->wrapWithColor($afterIndicator, $contentColor) + . $this->wrapWithColor($rightBorder, $borderColor); + } + + private function decorateDividerLine(string $line, ?Color $contentColor, int $lineIndex): string + { + $visibleLine = mb_substr($line, 0, $this->width); + $visibleLength = mb_strlen($visibleLine); + + if ($visibleLength <= 1) { + return parent::decorateContentLine($line, $contentColor, $lineIndex); + } + + $leftBorder = mb_substr($visibleLine, 0, 1); + $middle = $visibleLength > 2 ? mb_substr($visibleLine, 1, $visibleLength - 2) : ''; + $rightBorder = mb_substr($visibleLine, -1); + $borderColor = $this->hasFocus() ? $this->focusBorderColor : $contentColor; + $indicatorStart = $this->padding->leftPadding + $this->activeTabOffset; + $indicatorLength = $this->activeTabLength; + $beforeIndicator = mb_substr($middle, 0, $indicatorStart); + $indicator = mb_substr($middle, $indicatorStart, $indicatorLength); + $afterIndicator = mb_substr($middle, $indicatorStart + $indicatorLength); + + return $this->wrapWithColor($leftBorder, $borderColor) + . $this->wrapWithColor($beforeIndicator, $contentColor) + . $this->wrapWithColor($indicator, $this->activeIndicatorColor) + . $this->wrapWithColor($afterIndicator, $contentColor) + . $this->wrapWithColor($rightBorder, $borderColor); } private function colorizeLogTag(string $content): string @@ -151,25 +348,184 @@ private function scrollToRecentLines(): void if ($messageCount === 0) { $this->scrollOffset = 0; + $this->persistScrollOffset(); return; } $this->scrollOffset = max(0, $messageCount - self::INITIAL_TAIL_LINE_COUNT); + $this->persistScrollOffset(); } private function clampScrollOffset(): void { - if ($this->messages === []) { - $this->scrollOffset = 0; - return; + $this->scrollOffset = $this->clampScrollOffsetValue($this->scrollOffset, count($this->messages)); + $this->persistScrollOffset(); + } + + private function clampScrollOffsetValue(int $scrollOffset, int $messageCount): int + { + if ($messageCount === 0) { + return 0; } - $this->scrollOffset = max(0, min($this->scrollOffset, count($this->messages) - 1)); + return max(0, min($scrollOffset, $messageCount - 1)); } private function refreshVisibleContent(): void { + $this->updateHelpInfo(); + $this->rebuildMessages(); $this->clampScrollOffset(); - $this->content = array_slice($this->messages, $this->scrollOffset, $this->innerHeight); + $tabsLine = $this->buildTabsLine(); + $dividerWidth = max(0, $this->innerWidth - 2); + $dividerLine = $this->buildDividerLine($dividerWidth); + $visibleLineCount = $this->getVisibleLogLineCount(); + $visibleMessages = array_slice($this->messages, $this->scrollOffset, $visibleLineCount); + + $this->content = [ + $tabsLine, + $dividerLine, + ...$visibleMessages, + ]; + } + + private function rebuildMessages(): void + { + $this->messages = $this->messagesForTab($this->getActiveTab()); + } + + private function rebuildMessagesForTab(string $tabTitle): void + { + if ($tabTitle !== $this->getActiveTab()) { + return; + } + + $this->rebuildMessages(); + } + + private function messagesForTab(string $tabTitle): array + { + return [ + ...($this->logMessagesByTab[$tabTitle] ?? []), + ...($this->sessionMessagesByTab[$tabTitle] ?? []), + ]; + } + + private function shouldRefreshFromLogFile(): bool + { + if (!$this->isPlayModeActive) { + return false; + } + + return (microtime(true) - $this->lastLogRefreshAt) >= $this->refreshIntervalSeconds; + } + + private function loadLogLinesForTab(string $tabTitle): array + { + $logFilePath = $this->resolveLogFilePathForTab($tabTitle); + + if (!is_string($logFilePath) || $logFilePath === '' || !is_file($logFilePath)) { + return []; + } + + $lines = file($logFilePath, FILE_IGNORE_NEW_LINES); + + return $lines === false ? [] : array_values($lines); + } + + private function resolveLogFilePathForTab(string $tabTitle): ?string + { + return match ($tabTitle) { + 'Debug' => $this->logFilePath, + 'Error' => $this->errorLogFilePath, + default => null, + }; + } + + private function resolveSessionTabTitle(string $message): string + { + return preg_match('/\[ERROR\]/', $message) === 1 ? 'Error' : 'Debug'; + } + + private function resolveRecentScrollOffsetForTab(string $tabTitle): int + { + $messageCount = count($this->messagesForTab($tabTitle)); + + if ($messageCount === 0) { + return 0; + } + + return max(0, $messageCount - self::INITIAL_TAIL_LINE_COUNT); + } + + private function resolveLatestVisibleScrollOffsetForTab(string $tabTitle): int + { + $messageCount = count($this->messagesForTab($tabTitle)); + + if ($messageCount === 0) { + return 0; + } + + return max(0, $messageCount - $this->getVisibleLogLineCount()); + } + + private function updateHelpInfo(): void + { + $this->help = $this->isPlayModeActive + ? 'Tab/Shift+Tab tabs Up/Down scroll Auto refresh on' + : 'Tab/Shift+Tab tabs Up/Down scroll Shift+R refresh'; + } + + private function buildTabsLine(): string + { + $tabsLine = ''; + $this->activeTabOffset = 0; + + foreach (self::TAB_TITLES as $index => $tabTitle) { + if ($index > 0) { + $tabsLine .= ' '; + } + + if ($index === $this->activeTabIndex) { + $this->activeTabOffset = mb_strlen($tabsLine); + } + + $tabsLine .= $tabTitle; + } + + $this->activeTabLength = mb_strlen($this->getActiveTab()); + + return $tabsLine; + } + + private function buildDividerLine(int $dividerWidth): string + { + if ($dividerWidth <= 0) { + return ''; + } + + $characters = array_fill(0, $dividerWidth, self::DIVIDER_LINE_CHARACTER); + + for ($index = 0; $index < $this->activeTabLength; $index++) { + $characterIndex = $this->activeTabOffset + $index; + + if (!isset($characters[$characterIndex])) { + break; + } + + $characters[$characterIndex] = self::TAB_DIVIDER_LINE_CHARACTER; + } + + return implode('', $characters); + } + + private function getVisibleLogLineCount(): int + { + return max(1, $this->innerHeight - 2); + } + + private function persistScrollOffset(): void + { + $this->scrollOffsetsByTab[$this->getActiveTab()] = $this->scrollOffset; } } diff --git a/src/Editor/Widgets/Controls/InputControl.php b/src/Editor/Widgets/Controls/InputControl.php index bb49058..34b1c63 100644 --- a/src/Editor/Widgets/Controls/InputControl.php +++ b/src/Editor/Widgets/Controls/InputControl.php @@ -26,6 +26,11 @@ public function getValue(): mixed return $this->value; } + public function getIndentLevel(): int + { + return $this->indentLevel; + } + public function setValue(mixed $value): void { $this->value = $value; diff --git a/src/Editor/Widgets/Controls/SectionControl.php b/src/Editor/Widgets/Controls/SectionControl.php new file mode 100644 index 0000000..c12b033 --- /dev/null +++ b/src/Editor/Widgets/Controls/SectionControl.php @@ -0,0 +1,44 @@ +isCollapsed; + } + + public function toggleCollapsed(): void + { + $this->isCollapsed = !$this->isCollapsed; + } + + public function renderLines(): array + { + return [ + $this->indentation() . ($this->isCollapsed ? self::COLLAPSED_ICON : self::EXPANDED_ICON) . ' ' . $this->label, + ]; + } + + public function renderLineDefinitions(): array + { + return [[ + 'text' => $this->renderLines()[0], + 'state' => $this->isEditing() ? 'editing' : ($this->hasFocus() ? 'selected' : 'normal'), + 'kind' => 'section_header', + ]]; + } +} diff --git a/src/Editor/Widgets/InspectorPanel.php b/src/Editor/Widgets/InspectorPanel.php index d70adaa..43e02cc 100644 --- a/src/Editor/Widgets/InspectorPanel.php +++ b/src/Editor/Widgets/InspectorPanel.php @@ -12,6 +12,7 @@ use Sendama\Console\Editor\Widgets\Controls\NumberInputControl; use Sendama\Console\Editor\Widgets\Controls\PathInputControl; use Sendama\Console\Editor\Widgets\Controls\PreviewWindowControl; +use Sendama\Console\Editor\Widgets\Controls\SectionControl; use Sendama\Console\Editor\Widgets\Controls\TextInputControl; use Sendama\Console\Editor\Widgets\Controls\VectorInputControl; @@ -22,8 +23,8 @@ class InspectorPanel extends Widget private const string STATE_CONTROL_EDIT = 'control_edit'; 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 SECTION_ICON = '▼'; 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"; @@ -317,6 +318,8 @@ protected function decorateContentLine(string $line, ?Color $contentColor, int $ private function decorateSectionHeaderLine(string $line, ?Color $contentColor, int $lineIndex): string { + $contentIndex = $lineIndex - $this->padding->topPadding; + $lineState = $this->lineStates[$contentIndex] ?? 'normal'; $visibleLine = mb_substr($line, 0, $this->width); $visibleLength = mb_strlen($visibleLine); @@ -328,9 +331,12 @@ private function decorateSectionHeaderLine(string $line, ?Color $contentColor, i $middle = $visibleLength > 2 ? mb_substr($visibleLine, 1, $visibleLength - 2) : ''; $rightBorder = mb_substr($visibleLine, -1); $borderColor = $this->hasFocus() ? $this->focusBorderColor : $contentColor; + $sectionSequence = $lineState === 'selected' && $this->hasFocus() + ? self::SECTION_HEADER_SELECTED_SEQUENCE + : self::SECTION_HEADER_SEQUENCE; return $this->wrapWithColor($leftBorder, $borderColor) - . $this->wrapWithSequence($middle, self::SECTION_HEADER_SEQUENCE) + . $this->wrapWithSequence($middle, $sectionSequence) . $this->wrapWithColor($rightBorder, $borderColor); } @@ -376,7 +382,7 @@ private function buildHierarchyControls(array $target, array $item): void ['tag'], ); - $this->addSectionHeader('Transform'); + $this->addControl($this->addSectionHeader('Transform')); $this->addBoundControl( new VectorInputControl('Position', $this->normalizeVector($item['position'] ?? null), 1), ['position'], @@ -397,7 +403,7 @@ private function buildHierarchyControls(array $target, array $item): void ); } - $this->addSectionHeader('Renderer'); + $this->addControl($this->addSectionHeader('Renderer')); $this->addRendererControls($item); $this->addScriptComponents($item['components'] ?? []); } @@ -506,31 +512,123 @@ private function addScriptComponents(mixed $components): void continue; } - $this->addSectionHeader($this->resolveClassName($component['class'] ?? null, 'Component')); + $serializedComponentData = is_array($component['data'] ?? null) ? $component['data'] : null; - foreach ($component as $key => $value) { - if ($key === 'class') { - continue; - } + if (is_array($serializedComponentData)) { + $this->addControl( + $this->addSectionHeader( + $this->resolveClassName($component['class'] ?? null, 'Component'), + ) + ); + $this->addComponentPropertyControls( + $serializedComponentData, + ['components', $componentIndex, 'data'], + ); + continue; + } + + $legacyComponentData = array_filter( + $component, + static fn(string $key): bool => $key !== 'class', + ARRAY_FILTER_USE_KEY, + ); + + $this->addControl( + $this->addSectionHeader( + $this->resolveClassName($component['class'] ?? null, 'Component'), + ) + ); + + if (!is_array($legacyComponentData) || $legacyComponentData === []) { + continue; + } - $this->addBoundControl( - $this->inputControlFactory->create( - $this->humanizeKey((string) $key), - $value, - 1, - ), - ['components', $componentIndex, $key], + $this->addComponentPropertyControls( + $legacyComponentData, + ['components', $componentIndex], + ); + } + } + + private function addComponentPropertyControls(array $properties, array $basePath, int $indentLevel = 1): void + { + foreach ($properties as $key => $value) { + if (!is_string($key)) { + continue; + } + + if ($this->shouldRenderNestedComponentProperties($value)) { + $this->addControl($this->addSectionHeader( + $this->humanizeKey($key), + $indentLevel, + )); + $this->addComponentPropertyControls( + $value, + [...$basePath, $key], + $indentLevel + 1, ); + continue; } + + $this->addBoundControl( + $this->inputControlFactory->create( + $this->humanizeKey($key), + $value, + $indentLevel, + ), + [...$basePath, $key], + ); } } - private function addSectionHeader(string $title): void + private function shouldRenderNestedComponentProperties(mixed $value): bool { - $this->elements[] = [ - 'kind' => 'section_header', - 'text' => self::SECTION_ICON . ' ' . $title, - ]; + if (!is_array($value) || $value === []) { + return false; + } + + if ($this->isVectorValue($value) || $this->isScalarListValue($value)) { + return false; + } + + return true; + } + + private function isVectorValue(array $value): bool + { + foreach (array_keys($value) as $key) { + if (!is_string($key) || !in_array($key, ['x', 'y', 'z', 'w'], true)) { + return false; + } + } + + foreach ($value as $item) { + if (!is_scalar($item) && $item !== null) { + return false; + } + } + + return true; + } + + private function isScalarListValue(array $value): bool + { + if (!array_is_list($value)) { + return false; + } + + foreach ($value as $item) { + if (!is_scalar($item) && $item !== null) { + return false; + } + } + + return true; + } + + private function addSectionHeader(string $title, int $indentLevel = 0): SectionControl + { + return new SectionControl($title, $indentLevel); } private function addControl(InputControl $control): void @@ -544,38 +642,52 @@ private function addControl(InputControl $control): void private function addBoundControl(InputControl $control, array $valuePath): void { - $this->controlBindings[spl_object_id($control)] = $valuePath; + $this->bindControl($control, $valuePath); $this->addControl($control); } + private function bindControl(InputControl $control, array $valuePath): void + { + $this->controlBindings[spl_object_id($control)] = $valuePath; + } + private function refreshContent(): void { $this->refreshDerivedControls(); $content = []; $lineKinds = []; $lineStates = []; + $collapsedSectionIndentLevels = []; foreach ($this->elements as $element) { - $kind = $element['kind'] ?? 'plain'; + $control = $element['control'] ?? null; - if ($kind === 'section_header') { - $content[] = $element['text'] ?? ''; - $lineKinds[] = 'section_header'; - $lineStates[] = 'normal'; + if (!$control instanceof InputControl) { continue; } - $control = $element['control'] ?? null; + $controlIndentLevel = $control->getIndentLevel(); - if (!$control instanceof InputControl) { + while ( + $collapsedSectionIndentLevels !== [] + && $controlIndentLevel <= end($collapsedSectionIndentLevels) + ) { + array_pop($collapsedSectionIndentLevels); + } + + if ($collapsedSectionIndentLevels !== []) { continue; } foreach ($control->renderLineDefinitions() as $lineDefinition) { $content[] = $lineDefinition['text'] ?? ''; - $lineKinds[] = 'control'; + $lineKinds[] = $lineDefinition['kind'] ?? 'control'; $lineStates[] = $lineDefinition['state'] ?? 'normal'; } + + if ($control instanceof SectionControl && $control->isCollapsed()) { + $collapsedSectionIndentLevels[] = $controlIndentLevel; + } } $this->content = $content; @@ -638,13 +750,31 @@ private function getSelectedControl(): ?InputControl private function moveControlSelection(int $offset): void { - if ($this->focusableControls === []) { + $visibleControlIndexes = $this->resolveVisibleControlIndexes(); + + if ($visibleControlIndexes === []) { + return; + } + + if ($this->selectedControlIndex === null || !in_array($this->selectedControlIndex, $visibleControlIndexes, true)) { + $this->selectedControlIndex = $visibleControlIndexes[0]; + $this->applyControlSelection(); + $this->refreshContent(); + return; + } + + $visibleControlPosition = array_search($this->selectedControlIndex, $visibleControlIndexes, true); + + if (!is_int($visibleControlPosition)) { + $this->selectedControlIndex = $visibleControlIndexes[0]; + $this->applyControlSelection(); + $this->refreshContent(); return; } - $this->selectedControlIndex ??= 0; - $this->selectedControlIndex = ($this->selectedControlIndex + $offset + count($this->focusableControls)) - % count($this->focusableControls); + $nextVisibleControlPosition = ($visibleControlPosition + $offset + count($visibleControlIndexes)) + % count($visibleControlIndexes); + $this->selectedControlIndex = $visibleControlIndexes[$nextVisibleControlPosition]; $this->applyControlSelection(); $this->refreshContent(); } @@ -661,6 +791,12 @@ private function handleControlSelectionInput(InputControl $selectedControl): voi return; } + if (Input::isKeyDown(KeyCode::SLASH) && $selectedControl instanceof SectionControl) { + $selectedControl->toggleCollapsed(); + $this->refreshContent(); + return; + } + if (!Input::isKeyDown(KeyCode::ENTER)) { return; } @@ -1162,6 +1298,45 @@ private function applyControlValueToInspectionTarget(InputControl $control): voi ]; } + private function resolveVisibleControlIndexes(): array + { + $visibleControlIndexes = []; + $collapsedSectionIndentLevels = []; + + foreach ($this->elements as $element) { + $control = $element['control'] ?? null; + + if (!$control instanceof InputControl) { + continue; + } + + $controlIndentLevel = $control->getIndentLevel(); + + while ( + $collapsedSectionIndentLevels !== [] + && $controlIndentLevel <= end($collapsedSectionIndentLevels) + ) { + array_pop($collapsedSectionIndentLevels); + } + + if ($collapsedSectionIndentLevels !== []) { + continue; + } + + $controlIndex = array_search($control, $this->focusableControls, true); + + if (is_int($controlIndex)) { + $visibleControlIndexes[] = $controlIndex; + } + + if ($control instanceof SectionControl && $control->isCollapsed()) { + $collapsedSectionIndentLevels[] = $controlIndentLevel; + } + } + + return $visibleControlIndexes; + } + private function setNestedValue(array &$value, array $path, mixed $nextValue): void { $current = &$value; diff --git a/src/Editor/Widgets/MainPanel.php b/src/Editor/Widgets/MainPanel.php index 1f60340..7b53cef 100644 --- a/src/Editor/Widgets/MainPanel.php +++ b/src/Editor/Widgets/MainPanel.php @@ -26,6 +26,7 @@ class MainPanel extends Widget private const string SPRITE_CURSOR_FOCUSED_SEQUENCE = "\033[5;30;47m"; 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 string SPRITE_MODAL_CREATE = 'create_asset'; @@ -744,22 +745,22 @@ private function handleSceneMoveModeInput(): bool $this->syncSelectedScenePath(); - if (Input::isKeyDown(KeyCode::UP)) { + if (Input::isKeyPressed(KeyCode::UP)) { $this->moveSelectedSceneObject(0, -1); return true; } - if (Input::isKeyDown(KeyCode::RIGHT)) { + if (Input::isKeyPressed(KeyCode::RIGHT)) { $this->moveSelectedSceneObject(1, 0); return true; } - if (Input::isKeyDown(KeyCode::DOWN)) { + if (Input::isKeyPressed(KeyCode::DOWN)) { $this->moveSelectedSceneObject(0, 1); return true; } - if (Input::isKeyDown(KeyCode::LEFT)) { + if (Input::isKeyPressed(KeyCode::LEFT)) { $this->moveSelectedSceneObject(-1, 0); return true; } @@ -1056,6 +1057,14 @@ private function decorateSceneLine(string $line, ?Color $contentColor, int $cont $highlightText = mb_substr($middle, $highlightStart, $highlightLength); $afterHighlight = mb_substr($middle, $highlightStart + $highlightLength); + if (($highlight['kind'] ?? null) === 'placeholder') { + return $this->wrapWithColor($leftBorder, $borderColor) + . $this->wrapWithColor($beforeHighlight, $contentColor) + . $this->wrapWithColor($highlightText, Color::DARK_GRAY) + . $this->wrapWithColor($afterHighlight, $contentColor) + . $this->wrapWithColor($rightBorder, $borderColor); + } + return $this->wrapWithColor($leftBorder, $borderColor) . $this->wrapWithColor($beforeHighlight, $contentColor) . $this->wrapWithSequence($highlightText, $this->resolveSceneHighlightSequence()) @@ -1109,6 +1118,24 @@ private function buildSceneCanvasContent(): array ? $sceneObject['renderLines'] : []; + if ($renderLines === []) { + if (($sceneObject['path'] ?? null) !== $this->selectedScenePath) { + continue; + } + + if ($row < 0 || $row >= $canvasHeight || $column < 0 || $column >= $canvasWidth) { + continue; + } + + $canvas[$row][$column] = self::SCENE_PLACEHOLDER_CHARACTER; + $this->sceneLineHighlights[2 + $row] = [ + 'start' => $column, + 'length' => 1, + 'kind' => 'placeholder', + ]; + continue; + } + foreach ($renderLines as $lineOffset => $renderLine) { $targetRow = $row + $lineOffset; @@ -1222,15 +1249,12 @@ private function flattenSceneObjects(array $items, string $parentPath = 'scene') $path = $parentPath . '.' . $index; $renderLines = $this->resolveSceneObjectRenderLines($item); - - if ($renderLines !== []) { - $flattenedObjects[] = [ - 'path' => $path, - 'item' => $item, - 'position' => $this->normalizeVector($item['position'] ?? null), - 'renderLines' => $renderLines, - ]; - } + $flattenedObjects[] = [ + 'path' => $path, + 'item' => $item, + 'position' => $this->normalizeVector($item['position'] ?? null), + 'renderLines' => $renderLines, + ]; if (is_array($item['children'] ?? null) && $item['children'] !== []) { $flattenedObjects = [ diff --git a/tests/Unit/ConsolePanelTest.php b/tests/Unit/ConsolePanelTest.php index 3b91b2c..3ef26b7 100644 --- a/tests/Unit/ConsolePanelTest.php +++ b/tests/Unit/ConsolePanelTest.php @@ -2,7 +2,7 @@ use Sendama\Console\Editor\Widgets\ConsolePanel; -test('console panel loads the last three log lines on startup', function () { +test('console panel loads the last three debug log lines on startup', function () { $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); mkdir($workspace . '/logs', 0777, true); @@ -22,13 +22,93 @@ logFilePath: $workspace . '/logs/debug.log', ); - expect($panel->content)->toBe([ + expect($panel->getActiveTab())->toBe('Debug'); + expect(array_slice($panel->content, 2))->toBe([ '[2026-03-11 10:00:01] [INFO] - Second', '[2026-03-11 10:00:02] [WARN] - Third', '[2026-03-11 10:00:03] [ERROR] - Fourth', ]); }); +test('console panel switches between debug and error tabs', function () { + $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); + mkdir($workspace . '/logs', 0777, true); + + file_put_contents( + $workspace . '/logs/debug.log', + implode(PHP_EOL, [ + 'debug 1', + 'debug 2', + 'debug 3', + ]) . PHP_EOL + ); + + file_put_contents( + $workspace . '/logs/error.log', + implode(PHP_EOL, [ + 'error 1', + 'error 2', + ]) . PHP_EOL + ); + + $panel = new ConsolePanel( + width: 60, + height: 8, + logFilePath: $workspace . '/logs/debug.log', + errorLogFilePath: $workspace . '/logs/error.log', + ); + + expect($panel->content[0])->toContain('Debug'); + expect($panel->content[0])->toContain('Error'); + expect(array_slice($panel->content, 2))->toBe([ + 'debug 1', + 'debug 2', + 'debug 3', + ]); + + $panel->cycleFocusForward(); + + expect($panel->getActiveTab())->toBe('Error'); + expect(array_slice($panel->content, 2))->toBe([ + 'error 1', + 'error 2', + ]); + + $panel->cycleFocusBackward(); + + expect($panel->getActiveTab())->toBe('Debug'); +}); + +test('console panel ignores missing tab log files', function () { + $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); + mkdir($workspace . '/logs', 0777, true); + + file_put_contents( + $workspace . '/logs/error.log', + implode(PHP_EOL, [ + '[2026-03-11 10:00:01] [ERROR] - First', + '[2026-03-11 10:00:02] [ERROR] - Second', + ]) . PHP_EOL + ); + + $panel = new ConsolePanel( + width: 60, + height: 8, + logFilePath: $workspace . '/logs/debug.log', + errorLogFilePath: $workspace . '/logs/error.log', + ); + + expect(array_slice($panel->content, 2))->toBe([]); + + $panel->cycleFocusForward(); + + expect($panel->getActiveTab())->toBe('Error'); + expect(array_slice($panel->content, 2))->toBe([ + '[2026-03-11 10:00:01] [ERROR] - First', + '[2026-03-11 10:00:02] [ERROR] - Second', + ]); +}); + test('console panel scrolls upward through older log lines', function () { $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); mkdir($workspace . '/logs', 0777, true); @@ -46,11 +126,11 @@ $panel = new ConsolePanel( width: 40, - height: 6, + height: 8, logFilePath: $workspace . '/logs/debug.log', ); - expect($panel->content)->toBe([ + expect(array_slice($panel->content, 2))->toBe([ 'line 3', 'line 4', 'line 5', @@ -58,7 +138,7 @@ $panel->scrollUp(); - expect($panel->content)->toBe([ + expect(array_slice($panel->content, 2))->toBe([ 'line 2', 'line 3', 'line 4', @@ -68,7 +148,7 @@ $panel->scrollUp(); $panel->scrollUp(); - expect($panel->content)->toBe([ + expect(array_slice($panel->content, 2))->toBe([ 'line 1', 'line 2', 'line 3', @@ -93,7 +173,7 @@ $panel = new ConsolePanel( width: 40, - height: 6, + height: 8, logFilePath: $workspace . '/logs/debug.log', ); @@ -102,7 +182,159 @@ $panel->scrollDown(); $panel->scrollDown(); - expect($panel->content)->toBe([ + expect(array_slice($panel->content, 2))->toBe([ + 'line 5', + ]); +}); + +test('console panel refreshes the active tab from disk on shift+r when focused', function () { + $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); + mkdir($workspace . '/logs', 0777, true); + + $logFilePath = $workspace . '/logs/error.log'; + + file_put_contents( + $logFilePath, + implode(PHP_EOL, [ + 'line 1', + 'line 2', + 'line 3', + ]) . PHP_EOL + ); + + $panel = new ConsolePanel( + width: 40, + height: 8, + errorLogFilePath: $logFilePath, + ); + + $panel->cycleFocusForward(); + + file_put_contents( + $logFilePath, + implode(PHP_EOL, [ + 'line 1', + 'line 2', + 'line 3', + 'line 4', + 'line 5', + 'line 6', + ]) . PHP_EOL + ); + + $hasFocus = new ReflectionProperty(\Sendama\Console\Editor\Widgets\Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + $previousKeyPress->setValue(''); + $keyPress->setValue('R'); + + $panel->update(); + + expect($panel->getActiveTab())->toBe('Error'); + expect(array_slice($panel->content, 2))->toBe([ + 'line 3', + 'line 4', + 'line 5', + 'line 6', + ]); +}); + +test('console panel does not auto refresh outside play mode', function () { + $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); + mkdir($workspace . '/logs', 0777, true); + + $logFilePath = $workspace . '/logs/debug.log'; + + file_put_contents( + $logFilePath, + implode(PHP_EOL, [ + 'line 1', + 'line 2', + 'line 3', + ]) . PHP_EOL + ); + + $panel = new ConsolePanel( + width: 40, + height: 8, + logFilePath: $logFilePath, + refreshIntervalSeconds: 1.0, + ); + + file_put_contents( + $logFilePath, + implode(PHP_EOL, [ + 'line 1', + 'line 2', + 'line 3', + 'line 4', + 'line 5', + ]) . PHP_EOL + ); + + $lastLogRefreshAt = new ReflectionProperty(ConsolePanel::class, 'lastLogRefreshAt'); + $lastLogRefreshAt->setAccessible(true); + $lastLogRefreshAt->setValue($panel, microtime(true) - 2); + + $panel->update(); + + expect(array_slice($panel->content, 2))->toBe([ + 'line 1', + 'line 2', + 'line 3', + ]); +}); + +test('console panel automatically refreshes from the active tab log file during play mode', function () { + $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); + mkdir($workspace . '/logs', 0777, true); + + $logFilePath = $workspace . '/logs/debug.log'; + + file_put_contents( + $logFilePath, + implode(PHP_EOL, [ + 'line 1', + 'line 2', + 'line 3', + ]) . PHP_EOL + ); + + $panel = new ConsolePanel( + width: 40, + height: 8, + logFilePath: $logFilePath, + refreshIntervalSeconds: 1.0, + ); + + $panel->setPlayModeActive(true); + + file_put_contents( + $logFilePath, + implode(PHP_EOL, [ + 'line 1', + 'line 2', + 'line 3', + 'line 4', + 'line 5', + ]) . PHP_EOL + ); + + $lastLogRefreshAt = new ReflectionProperty(ConsolePanel::class, 'lastLogRefreshAt'); + $lastLogRefreshAt->setAccessible(true); + $lastLogRefreshAt->setValue($panel, microtime(true) - 2); + + $panel->update(); + + expect(array_slice($panel->content, 2))->toBe([ + 'line 2', + 'line 3', + 'line 4', 'line 5', ]); }); diff --git a/tests/Unit/EditorSettingsTest.php b/tests/Unit/EditorSettingsTest.php new file mode 100644 index 0000000..73697d1 --- /dev/null +++ b/tests/Unit/EditorSettingsTest.php @@ -0,0 +1,66 @@ + [ + 'scenes' => [ + 'active' => 1, + 'loaded' => [ + 'Scenes/alpha.scene.php', + 'Scenes/beta.scene.php', + ], + ], + 'console' => [ + 'refreshInterval' => 2.5, + ], + ], + ]); + + expect($settings->scenes->active)->toBe(1); + expect($settings->scenes->loaded)->toBe([ + 'Scenes/alpha.scene.php', + 'Scenes/beta.scene.php', + ]); + expect($settings->consoleRefreshIntervalSeconds)->toBe(2.5); +}); + +test('editor settings default the console refresh interval to five seconds', function () { + $settings = EditorSettings::fromArray([ + 'editor' => [ + 'scenes' => [ + 'active' => 0, + 'loaded' => ['Scenes/level01.scene.php'], + ], + ], + ]); + + expect($settings->consoleRefreshIntervalSeconds)->toBe(5.0); +}); + +test('editor settings load editor config from sendama json', function () { + $workspace = sys_get_temp_dir() . '/sendama-editor-settings-' . uniqid(); + mkdir($workspace, 0777, true); + + file_put_contents( + $workspace . '/sendama.json', + json_encode([ + 'name' => 'blasters', + 'editor' => [ + 'scenes' => [ + 'active' => 0, + 'loaded' => ['Scenes/level01.scene.php'], + ], + 'console' => [ + 'refreshInterval' => 3, + ], + ], + ], JSON_PRETTY_PRINT) + ); + + $settings = EditorSettings::loadFromDirectory($workspace); + + expect($settings->scenes->loaded)->toBe(['Scenes/level01.scene.php']); + expect($settings->consoleRefreshIntervalSeconds)->toBe(3.0); +}); diff --git a/tests/Unit/InputManagerTest.php b/tests/Unit/InputManagerTest.php index 59d2551..8b4562e 100644 --- a/tests/Unit/InputManagerTest.php +++ b/tests/Unit/InputManagerTest.php @@ -92,3 +92,29 @@ expect($tokenizeInput->invoke(null, "\033[B0"))->toBe(["\033[B", '0']); }); + +test('input manager coalesces repeated arrow tokens to avoid held-key drift', function () { + $coalesceRepeatableTokens = new ReflectionMethod(InputManager::class, 'coalesceRepeatableTokens'); + $coalesceRepeatableTokens->setAccessible(true); + + expect($coalesceRepeatableTokens->invoke(null, ["\033[C", "\033[C", "\033[C"]))->toBe([ + "\033[C", + ]); + expect($coalesceRepeatableTokens->invoke(null, ["\033[C", "\033[D", "\033[D"]))->toBe([ + "\033[C", + "\033[D", + ]); + expect($coalesceRepeatableTokens->invoke(null, ['0', '0']))->toBe(['0', '0']); +}); + +test('input manager treats repeated arrow input as pressed', function () { + $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + $previousKeyPress->setValue("\033[C"); + $keyPress->setValue("\033[C"); + + expect(InputManager::isKeyPressed(KeyCode::RIGHT))->toBeTrue(); + expect(InputManager::isKeyDown(KeyCode::RIGHT))->toBeFalse(); +}); diff --git a/tests/Unit/InspectorPanelTest.php b/tests/Unit/InspectorPanelTest.php index 35ebcf1..f6855a6 100644 --- a/tests/Unit/InspectorPanelTest.php +++ b/tests/Unit/InspectorPanelTest.php @@ -100,6 +100,77 @@ expect($renderedLine)->toContain("\033[30;47m"); }); +test('inspector panel styles focused section headers with a light blue background', function () { + $panelWidth = 32; + $panel = new InspectorPanel(width: $panelWidth, height: 12); + + $panel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'value' => [ + 'type' => 'GameObject::class', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 0, 'y' => 0], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [], + ], + ]); + + $hasFocus = new ReflectionProperty(\Sendama\Console\Editor\Widgets\Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + for ($index = 0; $index < 3; $index++) { + $panel->cycleFocusForward(); + } + + $decorateContentLine = new ReflectionMethod($panel, 'decorateContentLine'); + $decorateContentLine->setAccessible(true); + $line = '|' . str_pad($panel->content[3], $panelWidth - 2) . '|'; + $renderedLine = $decorateContentLine->invoke($panel, $line, null, 3); + + expect($renderedLine)->toContain("\033[30;104m"); +}); + +test('inspector panel renders serialized component data with typed controls', function () { + $panel = new InspectorPanel(width: 48, height: 24); + + $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\\PlayerController', + 'data' => [ + 'isPlayerControlled' => true, + 'maxBullets' => 10, + 'spawnOffset' => ['x' => 2, 'y' => 1], + ], + ], + ], + ], + ]); + + expect($panel->content)->toContain('▼ PlayerController'); + expect($panel->content)->toContain(' Is Player Controlled: [x]'); + expect($panel->content)->toContain(' Max Bullets: 10'); + expect($panel->content)->toContain(' Spawn Offset:'); + expect($panel->content)->toContain(' X: 2'); + expect($panel->content)->toContain(' Y: 1'); +}); + test('inspector panel resolves texture previews from the configured project directory', function () { $workspace = sys_get_temp_dir() . '/sendama-inspector-project-root-' . uniqid(); mkdir($workspace . '/Assets/Textures', 0777, true); @@ -282,6 +353,62 @@ ]); }); +test('inspector panel toggles focused sections with slash', function () { + $panel = new InspectorPanel(width: 48, height: 24); + + $panel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + '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\\PlayerController', + 'data' => [ + 'maxBullets' => 10, + ], + ], + ], + ], + ]); + + $hasFocus = new ReflectionProperty(\Sendama\Console\Editor\Widgets\Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + + for ($index = 0; $index < 3; $index++) { + $panel->cycleFocusForward(); + } + + expect($panel->content)->toContain('▼ Transform'); + expect($panel->content)->toContain(' Position:'); + + $previousKeyPress->setValue(''); + $keyPress->setValue('/'); + $panel->update(); + + expect($panel->content)->toContain('▶ Transform'); + expect($panel->content)->not->toContain(' Position:'); + + $previousKeyPress->setValue(''); + $keyPress->setValue('/'); + $panel->update(); + + expect($panel->content)->toContain('▼ Transform'); + expect($panel->content)->toContain(' Position:'); +}); + test('inspector panel opens the path action modal for path controls', function () { $workspace = sys_get_temp_dir() . '/sendama-inspector-path-modal-' . uniqid(); mkdir($workspace . '/Assets/Textures', 0777, true); diff --git a/tests/Unit/MainPanelTest.php b/tests/Unit/MainPanelTest.php index 95bc8a3..1f96017 100644 --- a/tests/Unit/MainPanelTest.php +++ b/tests/Unit/MainPanelTest.php @@ -324,6 +324,42 @@ function createMainPanelWorkspace(): string ]); }); +test('main panel shows a muted marker for selected non-renderable scene objects', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel( + width: 40, + height: 12, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Game Manager', + 'position' => ['x' => 3, 'y' => 2], + ], + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 10, 'y' => 4], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + ); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + pressMainPanelKey('Q'); + $panel->update(); + + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'x')))->toBeTrue(); +}); + test('main panel move mode updates the selected scene object position', function () { $workspace = createMainPanelWorkspace(); $panel = new MainPanel( @@ -449,6 +485,60 @@ function createMainPanelWorkspace(): string ]); }); +test('main panel move mode continues moving when repeated direction input is held', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel( + width: 40, + height: 12, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 2, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + ); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + pressMainPanelKey('W'); + $panel->update(); + + $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + + $previousKeyPress->setValue("\033[C"); + $keyPress->setValue("\033[C"); + $panel->update(); + + expect($panel->consumeHierarchyMutation())->toBe([ + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 3, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ]); +}); + test('main panel pan mode scrolls the scene viewport', function () { $workspace = createMainPanelWorkspace(); $panel = new MainPanel( diff --git a/tests/Unit/SceneLoaderTest.php b/tests/Unit/SceneLoaderTest.php index 0c5e50a..1b3ce11 100644 --- a/tests/Unit/SceneLoaderTest.php +++ b/tests/Unit/SceneLoaderTest.php @@ -151,6 +151,415 @@ class Label ]); }); +test('scene loader enriches component entries with serialized component data for editor use', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-loader-components-' . 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 function __serialize(): array + { + return [ + 'texture' => $this->texture->path, + 'rect' => $this->rect, + ]; + } + } + + 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 ?Sprite $sprite = null, + ) { + } + + public function getName(): string + { + return $this->name; + } + } + + abstract class Component + { + public function __construct(private readonly GameObject $gameObject) + { + } + + public function __serialize(): array + { + $data = []; + $properties = (new ReflectionObject($this))->getProperties(); + + foreach ($properties as $property) { + if ($property->isPublic() || $property->getAttributes(SerializeField::class)) { + $data[$property->getName()] = $property->getValue($this); + } + } + + return $data; + } + } +} + +namespace Sendama\Game { + use Sendama\Engine\Core\Behaviours\Attributes\SerializeField; + use Sendama\Engine\Core\Component; + use Sendama\Engine\Core\GameObject; + use Sendama\Engine\Core\Vector2; + + class PlayerController extends Component + { + public bool $enabledInEditor = true; + + #[SerializeField] + protected int $speed = 3; + + public Vector2|array $spawnOffset; + + public function __construct(GameObject $gameObject) + { + $this->spawnOffset = new Vector2(2, 1); + parent::__construct($gameObject); + } + } +} +PHP + ); + + file_put_contents( + $workspace . '/assets/Scenes/level01.scene.php', + <<<'PHP' + [ + [ + 'type' => GameObject::class, + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + 'components' => [ + ['class' => 'Sendama\\Game\\PlayerController'], + ], + ], + ], +]; +PHP + ); + + $loader = new SceneLoader($workspace); + $scene = $loader->load(new EditorSceneSettings(active: 0, loaded: ['level01'])); + + expect($scene)->not->toBeNull(); + expect($scene->sourceData['hierarchy'][0]['components'])->toBe([ + ['class' => 'Sendama\\Game\\PlayerController'], + ]); + expect($scene->hierarchy[0]['components'])->toBe([ + [ + 'class' => 'Sendama\\Game\\PlayerController', + 'data' => [ + 'enabledInEditor' => true, + 'speed' => 3, + 'spawnOffset' => ['x' => 2, 'y' => 1], + ], + ], + ]); +}); + +test('scene loader backfills empty saved component data from serialized defaults', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-loader-component-merge-' . 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, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function getScene(): ?\Sendama\Engine\Core\Scenes\Interfaces\SceneInterface + { + return null; + } + } + + abstract class Component + { + public function __construct(private readonly GameObject $gameObject) + { + } + + public function getGameObject(): GameObject + { + return $this->gameObject; + } + + public function __serialize(): array + { + $data = []; + $properties = (new ReflectionObject($this))->getProperties(); + + foreach ($properties as $property) { + if ($property->isPublic() || $property->getAttributes(\Sendama\Engine\Core\Behaviours\Attributes\SerializeField::class)) { + $data[$property->getName()] = $property->getValue($this); + } + } + + return $data; + } + } +} + +namespace Sendama\Engine\Core\Behaviours { + use Sendama\Engine\Core\Component; + use Sendama\Engine\Core\GameObject; + use Sendama\Engine\Core\Scenes\Interfaces\SceneInterface; + + abstract class Behaviour extends Component + { + public SceneInterface $activeScene { + get { + $scene = $this->getGameObject()->getScene(); + + if (!$scene instanceof SceneInterface) { + throw new \RuntimeException('No active scene'); + } + + return $scene; + } + } + + public SceneInterface $scene { + get { + $scene = $this->getGameObject()->getScene(); + + if (!$scene instanceof SceneInterface) { + throw new \RuntimeException('No scene'); + } + + return $scene; + } + } + + public function __construct(GameObject $gameObject) + { + parent::__construct($gameObject); + } + } +} + +namespace Sendama\Engine\Core { + class Texture + { + public function __construct(public string $path) + { + } + + public function __toString(): string + { + return $this->path; + } + } +} + +namespace Sendama\Game { + use Sendama\Engine\Core\Behaviours\Attributes\SerializeField; + use Sendama\Engine\Core\Behaviours\Behaviour; + use Sendama\Engine\Core\Texture; + + class Gun extends Behaviour + { + #[SerializeField] + protected float $fireRate = 0.5; + + #[SerializeField] + protected int $maxBullets = 10; + + #[SerializeField] + protected ?Texture $bulletTexture = null; + } +} +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\\Gun', + '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\\Gun', + 'data' => [ + 'fireRate' => 0.5, + 'maxBullets' => 10, + 'bulletTexture' => null, + ], + ], + ]); +}); + test('scene loader falls back to the first available scene when none is configured', function () { $workspace = sys_get_temp_dir() . '/sendama-scene-loader-' . uniqid(); mkdir($workspace . '/assets/Scenes', 0777, true); diff --git a/tests/Unit/SceneWriterTest.php b/tests/Unit/SceneWriterTest.php index 8f8d82f..5f8008d 100644 --- a/tests/Unit/SceneWriterTest.php +++ b/tests/Unit/SceneWriterTest.php @@ -291,3 +291,231 @@ expect($serializedScene)->toContain("'type' => \\Sendama\\Engine\\Core\\GameObject::class"); expect($serializedScene)->not->toContain("'type' => 'GameObject::class'"); }); + +test('scene writer targets the final top-level scene return when helper returns exist earlier in the file', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-writer-helper-return-' . uniqid(); + mkdir($workspace . '/Assets/Scenes', 0777, true); + $scenePath = $workspace . '/Assets/Scenes/level01.scene.php'; + + file_put_contents( + $scenePath, + <<<'PHP' + [ + [ + 'type' => GameObject::class, + 'name' => 'Player', + 'tag' => 'Player', + ], + ], +]; +PHP + ); + + $scene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player 2', + ], + ], + sourcePath: $scenePath, + rawData: [ + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player 2', + ], + ], + ], + sourceData: [ + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + ], + ], + ], + ); + + $writer = new SceneWriter(); + $serializedScene = $writer->serialize($scene); + + expect($serializedScene)->toContain('return trim($value);'); + expect($serializedScene)->toContain("'name' => 'Player 2'"); + expect($serializedScene)->toContain("'tag' => 'Player'"); + expect(substr_count($serializedScene, 'return ['))->toBe(1); +}); + +test('scene writer matches list items by identity instead of shifted indexes when entries are deleted', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-writer-delete-shift-' . uniqid(); + mkdir($workspace . '/Assets/Scenes', 0777, true); + $scenePath = $workspace . '/Assets/Scenes/level01.scene.php'; + + file_put_contents( + $scenePath, + <<<'PHP' + [ + [ + 'type' => GameObject::class, + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 1, 'y' => 2], + ], + [ + 'type' => GameObject::class, + 'name' => 'Enemy', + 'tag' => 'Enemy', + 'position' => ['x' => 9, 'y' => 8], + ], + ], +]; +PHP + ); + + $scene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'GameObject::class', + 'name' => 'Enemy', + ], + ], + sourcePath: $scenePath, + rawData: [ + 'hierarchy' => [ + [ + 'type' => 'GameObject::class', + 'name' => 'Enemy', + ], + ], + ], + sourceData: [ + 'hierarchy' => [ + [ + 'type' => 'GameObject::class', + 'name' => 'Player', + ], + [ + 'type' => 'GameObject::class', + 'name' => 'Enemy', + ], + ], + ], + ); + + $writer = new SceneWriter(); + $serializedScene = $writer->serialize($scene); + + expect($serializedScene)->not->toContain("'name' => 'Player'"); + expect($serializedScene)->toContain("'name' => 'Enemy'"); + expect($serializedScene)->toContain("'tag' => 'Enemy'"); + expect($serializedScene)->toContain("'position' => ['x' => 9, 'y' => 8]"); + expect($serializedScene)->not->toContain("'tag' => 'Player'"); + expect($serializedScene)->not->toContain("'position' => ['x' => 1, 'y' => 2]"); +}); + +test('scene writer persists serialized component data added by the editor snapshot', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-writer-component-data-' . uniqid(); + mkdir($workspace . '/Assets/Scenes', 0777, true); + $scenePath = $workspace . '/Assets/Scenes/level01.scene.php'; + + file_put_contents( + $scenePath, + <<<'PHP' + [ + [ + 'type' => GameObject::class, + 'name' => 'Player', + 'components' => [ + [ + 'class' => PlayerController::class, + ], + ], + ], + ], +]; +PHP + ); + + $scene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'components' => [ + [ + 'class' => 'Sendama\\Game\\PlayerController', + 'data' => [ + 'enabledInEditor' => true, + 'speed' => 3, + 'spawnOffset' => ['x' => 2, 'y' => 1], + ], + ], + ], + ], + ], + sourcePath: $scenePath, + rawData: [ + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'components' => [ + [ + 'class' => 'Sendama\\Game\\PlayerController', + 'data' => [ + 'enabledInEditor' => true, + 'speed' => 3, + 'spawnOffset' => ['x' => 2, 'y' => 1], + ], + ], + ], + ], + ], + ], + sourceData: [ + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'components' => [ + [ + 'class' => 'Sendama\\Game\\PlayerController', + ], + ], + ], + ], + ], + ); + + $writer = new SceneWriter(); + $serializedScene = $writer->serialize($scene); + + expect(preg_match('/[\'"]class[\'"]\s*=>\s*\\\\Sendama\\\\Game\\\\PlayerController::class/', $serializedScene))->toBe(1); + expect(preg_match('/[\'"]data[\'"]\s*=>\s*\[/', $serializedScene))->toBe(1); + expect($serializedScene)->toContain("'enabledInEditor' => true"); + expect($serializedScene)->toContain("'speed' => 3"); + expect($serializedScene)->toContain("'spawnOffset' => ["); +});