From 5fd60a6a179281e23f679a9a9b1e6125c2ef804e Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 27 Feb 2026 20:13:19 +0200 Subject: [PATCH 1/6] feat(editor): enhance AssetsPanel and HierarchyPanel with asset management features and hierarchy notifications --- src/Editor/DTOs/HierarchyObjectDTO.php | 46 +++++++++++++++++++ src/Editor/DTOs/SceneDTO.php | 39 ++++++++++++++++ src/Editor/Editor.php | 9 +++- src/Editor/Events/Enumerations/EventType.php | 1 + src/Editor/Widgets/AssetsPanel.php | 48 +++++++++++++++++++- src/Editor/Widgets/HierarchyPanel.php | 46 +++++++++++++++++-- 6 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 src/Editor/DTOs/HierarchyObjectDTO.php create mode 100644 src/Editor/DTOs/SceneDTO.php diff --git a/src/Editor/DTOs/HierarchyObjectDTO.php b/src/Editor/DTOs/HierarchyObjectDTO.php new file mode 100644 index 0000000..0b3cc57 --- /dev/null +++ b/src/Editor/DTOs/HierarchyObjectDTO.php @@ -0,0 +1,46 @@ + $this->name, + 'tag' => $this->tag, + 'position' => $this->position, + 'rotation' => $this->rotation, + 'scale' => $this->scale + ]; + } + + public function __unserialize(array $data): void + { + $this->name = $data['name'] ?? ''; + $this->tag = $data['tag'] ?? ''; + $this->position = $data['position'] ?? [1, 1]; + $this->rotation = $data['rotation'] ?? [0, 0]; + $this->scale = $data['scale'] ?? [1, 1]; + } + + public static function fromArray(array $data): self + { + + } +} \ No newline at end of file diff --git a/src/Editor/DTOs/SceneDTO.php b/src/Editor/DTOs/SceneDTO.php new file mode 100644 index 0000000..88429ab --- /dev/null +++ b/src/Editor/DTOs/SceneDTO.php @@ -0,0 +1,39 @@ + $this->name, + "width" => $this->width, + "height" => $this->height, + "environmentTileMapPath" => $this->environmentTileMapPath, + "hierarchy" => $this->hierarchy, + ]; + } + + public function __unserialize(array $data): void + { + $this->name = $data['name'] ?? ''; + $this->width = $data['width'] ?? DEFAULT_TERMINAL_WIDTH; + $this->height = $data['height'] ?? DEFAULT_TERMINAL_HEIGHT; + $this->environmentTileMapPath = $data['environmentTileMapPath'] ?? "Maps/example"; + $this->hierarchy = $data['hierarchy'] ?? []; + } +} \ No newline at end of file diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php index 9645ae6..c22020a 100644 --- a/src/Editor/Editor.php +++ b/src/Editor/Editor.php @@ -25,6 +25,7 @@ use Sendama\Console\Editor\Widgets\Widget; use Sendama\Console\Exceptions\IOException; use Sendama\Console\Exceptions\SendamaConsoleException; +use Sendama\Console\Util\Path; use Symfony\Component\Console\Output\ConsoleOutput; use Throwable; @@ -198,6 +199,8 @@ public function start(): void Console::cursor()->hide(); + Console::enableMouseReporting(); + InputManager::disableEcho(); InputManager::enableNonBlockingMode(); @@ -227,14 +230,16 @@ public function stop(): void InputManager::enableEcho(); - Console::cursor()->show(); + Console::disableMouseReporting(); - $this->removeObservers(...$this->observers, ...$this->staticObservers); + Console::cursor()->show(); $this->isRunning = false; $this->notify(new EditorEvent(EventType::EDITOR_STOPPED->value, $this)); + $this->removeObservers(...$this->observers, ...$this->staticObservers); + Debug::info("Editor stopped"); } diff --git a/src/Editor/Events/Enumerations/EventType.php b/src/Editor/Events/Enumerations/EventType.php index ff482f1..2dc6d6b 100644 --- a/src/Editor/Events/Enumerations/EventType.php +++ b/src/Editor/Events/Enumerations/EventType.php @@ -12,4 +12,5 @@ enum EventType: string case EDITOR_RENDERED = 'frame_rendered'; case EDITOR_INPUT_HANDLED = 'editor_input_handled'; case KEYBOARD_INPUT = 'keyboard_input'; + case HIERARCHY_CHANGED = 'hierarchy_changed'; } diff --git a/src/Editor/Widgets/AssetsPanel.php b/src/Editor/Widgets/AssetsPanel.php index c833b54..0e2665b 100644 --- a/src/Editor/Widgets/AssetsPanel.php +++ b/src/Editor/Widgets/AssetsPanel.php @@ -2,15 +2,61 @@ namespace Sendama\Console\Editor\Widgets; +use Sendama\Console\Debug\Debug; +use Sendama\Console\Util\Path; + +/** + * AssetsPanel class. + * + * This panel is responsible for displaying the list of assets available in the game, such as scripts, textures, and + * other resources. It allows users to browse and manage their assets within the editor. + * + * @package Sendama\Console\Editor\Widgets + */ class AssetsPanel extends Widget { + /** + * AssetsPanel constructor. + * + * @param array $position The position of the panel in the editor (default: ['x' => 1, 'y' => 15]). + * @param int $width The width of the panel (default: 35). + * @param int $height The height of the panel (default: 14). + * @param string|null $assetsDirectoryPath The path to the assets directory (optional). + */ public function __construct( array $position = ['x' => 1, 'y' => 15], int $width = 35, - int $height = 14 + int $height = 14, + protected ?string $assetsDirectoryPath = null ) { parent::__construct('Assets', '', $position, $width, $height); + + if (!$this->assetsDirectoryPath) { + $this->assetsDirectoryPath = Path::getWorkingDirectoryAssetsPath(); + } + + if (! file_exists($this->assetsDirectoryPath) ) { + Debug::warn("Assets directory not found at {$this->assetsDirectoryPath}. Please create the directory and add your assets."); + } else { + $rootAssets = scandir($this->assetsDirectoryPath); + + if (false === $rootAssets) { + Debug::error("Failed to read contents of assets directory at {$this->assetsDirectoryPath}."); + } else { + $rootAssets = array_slice($rootAssets, 2); + $content = []; + foreach ($rootAssets as $asset) { + $contentLine = " $asset"; + if (is_dir(Path::join($this->assetsDirectoryPath, $asset))) { + $contentLine = "► $asset"; + } + + $content[] = $contentLine; + } + $this->content = $content; + } + } } /** diff --git a/src/Editor/Widgets/HierarchyPanel.php b/src/Editor/Widgets/HierarchyPanel.php index e86e568..c300804 100644 --- a/src/Editor/Widgets/HierarchyPanel.php +++ b/src/Editor/Widgets/HierarchyPanel.php @@ -2,18 +2,56 @@ namespace Sendama\Console\Editor\Widgets; +use Atatusoft\Termutil\Events\Interfaces\ObservableInterface; +use Atatusoft\Termutil\Events\Traits\ObservableTrait; +use Sendama\Console\Editor\Events\EditorEvent; +use Sendama\Console\Editor\Events\Enumerations\EventType; -use Sendama\Console\Debug\Debug; - -class HierarchyPanel extends Widget +/** + * HierarchyPanel class. + * + * @package + */ +class HierarchyPanel extends Widget implements ObservableInterface { + use ObservableTrait; + + public array $hierarchy { + get { + return $this->hierarchy; + } + + set { + $this->hierarchy = $value; + $this->notify(new EditorEvent(EventType::HIERARCHY_CHANGED->value, $this, )); + } + } + public function __construct( array $position = ['x' => 1, 'y' => 1], int $width = 35, - int $height = 14 + int $height = 14, + array $hierarchy = [ + [ + "name" => "Level Manager" + ], + [ + "name" => "Game Object" + ] + ] ) { + $this->initializeObservers(); + $this->hierarchy = $hierarchy; parent::__construct('Hierarchy', '', $position, $width, $height); + + // Bind hierarchy to content for display + $this->content = array_map(function (array $item) { + $objectName = $item['name'] ?? 'Unnamed Object'; + $icon = "►"; // TODO: Determine icon based on object type + // TODO: Add indentation based on hierarchy level + return "$icon $objectName"; + }, $this->hierarchy); } /** From a3a1d0924c2df2a9775842c439f14f912cce006c Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 11 Mar 2026 18:41:07 +0200 Subject: [PATCH 2/6] feat(editor): add ConsolePanel and MainPanel with tab navigation; enhance AssetsPanel and HierarchyPanel functionality --- src/Editor/Editor.php | 284 ++++++++++++++++++++++++-- src/Editor/IO/Input.php | 18 +- src/Editor/IO/InputManager.php | 54 ++++- src/Editor/SceneLoader.php | 188 +++++++++++++++++ src/Editor/Widgets/AssetsPanel.php | 64 ++++-- src/Editor/Widgets/ConsolePanel.php | 35 ++++ src/Editor/Widgets/HierarchyPanel.php | 78 ++++--- src/Editor/Widgets/MainPanel.php | 129 ++++++++++++ src/Editor/Widgets/PanelListModal.php | 126 ++++++++++++ src/Editor/Widgets/Widget.php | 134 +++++++++++- tests/Unit/MainPanelTest.php | 35 ++++ tests/Unit/SceneLoaderTest.php | 50 +++++ 12 files changed, 1133 insertions(+), 62 deletions(-) create mode 100644 src/Editor/SceneLoader.php create mode 100644 src/Editor/Widgets/ConsolePanel.php create mode 100644 src/Editor/Widgets/MainPanel.php create mode 100644 src/Editor/Widgets/PanelListModal.php create mode 100644 tests/Unit/MainPanelTest.php create mode 100644 tests/Unit/SceneLoaderTest.php diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php index c22020a..2ecbfa9 100644 --- a/src/Editor/Editor.php +++ b/src/Editor/Editor.php @@ -12,6 +12,7 @@ use Sendama\Console\Editor\Events\EditorEvent; use Sendama\Console\Editor\Events\Enumerations\EventType; use Sendama\Console\Editor\Interfaces\EditorStateInterface; +use Sendama\Console\Editor\IO\Input; use Sendama\Console\Editor\IO\InputManager; use Sendama\Console\Editor\States\EditorState; use Sendama\Console\Editor\States\EditorStateContext; @@ -20,12 +21,14 @@ use Sendama\Console\Editor\States\PlayState; use Sendama\Console\Editor\States\ProjectBrowserState; use Sendama\Console\Editor\Widgets\AssetsPanel; +use Sendama\Console\Editor\Widgets\ConsolePanel; use Sendama\Console\Editor\Widgets\HierarchyPanel; use Sendama\Console\Editor\Widgets\InspectorPanel; +use Sendama\Console\Editor\Widgets\MainPanel; +use Sendama\Console\Editor\Widgets\PanelListModal; use Sendama\Console\Editor\Widgets\Widget; use Sendama\Console\Exceptions\IOException; use Sendama\Console\Exceptions\SendamaConsoleException; -use Sendama\Console\Util\Path; use Symfony\Component\Console\Output\ConsoleOutput; use Throwable; @@ -115,7 +118,16 @@ final class Editor implements ObservableInterface protected ItemList $panels; protected HierarchyPanel $hierarchyPanel; protected AssetsPanel $assetsPanel; + protected MainPanel $mainPanel; + protected ConsolePanel $consolePanel; protected InspectorPanel $inspectorPanel; + protected ?Widget $focusedPanel = null; + protected ?DTOs\SceneDTO $loadedScene = null; + protected ?string $assetsDirectoryPath = null; + protected int $terminalWidth = DEFAULT_TERMINAL_WIDTH; + protected int $terminalHeight = DEFAULT_TERMINAL_HEIGHT; + protected PanelListModal $panelListModal; + protected bool $shouldRefreshBackgroundUnderModal = false; /** * @param string $name @@ -134,6 +146,8 @@ public function __construct( $this->initializeObservers(); $this->configureErrorAndExceptionHandlers(); $this->initializeSettings(); + $this->initializeLoadedScene(); + $this->refreshTerminalSize(force: true); $this->initializeManagers(); $this->initializeConsole(); $this->initializeWidgets(); @@ -195,7 +209,7 @@ public function start(): void Console::setName($this->gameSettings?->name ?? "Sendama Editor | Unknown Game"); - Console::setSize($this->gameSettings?->width, $this->gameSettings?->height); + Console::setSize($this->terminalWidth, $this->terminalHeight); Console::cursor()->hide(); @@ -300,7 +314,10 @@ public function setState(EditorStateInterface $editorState): void $this->gameSettings, [ 'hierarchy' => $this->hierarchyPanel, - 'assets' => $this->assetsPanel + 'assets' => $this->assetsPanel, + 'main' => $this->mainPanel, + 'console' => $this->consolePanel, + 'inspector' => $this->inspectorPanel, ] ); @@ -317,6 +334,7 @@ public function setState(EditorStateInterface $editorState): void private function handleInput(): void { InputManager::handleInput(); + $this->handlePanelFocus(); $this->notify(new EditorEvent(EventType::EDITOR_INPUT_HANDLED->value, $this)); } @@ -328,7 +346,12 @@ private function handleInput(): void */ private function update(): void { + if ($this->frameCount % 10 === 0) { + $this->refreshTerminalSize(); + } + $this->editorState->update(); + $this->handlePanelKeyboardWorkflow(); foreach ($this->panels as $panel) { $panel->update(); @@ -340,13 +363,36 @@ private function update(): void private function render(): void { $this->frameCount++; + if ($this->panelListModal->isVisible()) { + if ($this->shouldRefreshBackgroundUnderModal) { + $this->renderEditorFrame(); + } + + if ($this->shouldRefreshBackgroundUnderModal || $this->panelListModal->isDirty()) { + $this->panelListModal->render(); + $this->panelListModal->markClean(); + $this->shouldRefreshBackgroundUnderModal = false; + } + + $this->notify(new EditorEvent(EventType::EDITOR_RENDERED->value, $this)); + return; + } + + $this->shouldRefreshBackgroundUnderModal = false; + $this->renderEditorFrame(); + + $this->notify(new EditorEvent(EventType::EDITOR_RENDERED->value, $this)); + } + + private function renderEditorFrame(): void + { $this->editorState->render(); + foreach ($this->panels as $panel) { $panel->render(); } - $this->renderDebugInfo(); - $this->notify(new EditorEvent(EventType::EDITOR_RENDERED->value, $this)); + $this->renderDebugInfo(); } private function renderDebugInfo(): void @@ -417,8 +463,8 @@ private function handleError(int $errno, string $errstr, string $errfile, int $e protected function initializeConsole(): void { Console::init([ - "width" => $this->gameSettings?->width ?? DEFAULT_TERMINAL_WIDTH, - "height" => $this->gameSettings?->height ?? DEFAULT_TERMINAL_HEIGHT, + "width" => $this->terminalWidth, + "height" => $this->terminalHeight, ]); } @@ -442,6 +488,13 @@ private function initializeSettings(): void $this->gameSettings = GameSettings::loadFromDirectory($this->workingDirectory); } + private function initializeLoadedScene(): void + { + $sceneLoader = new SceneLoader($this->workingDirectory); + $this->assetsDirectoryPath = $sceneLoader->resolveAssetsDirectory(); + $this->loadedScene = $sceneLoader->load($this->settings->scenes); + } + /** * @return void */ @@ -456,15 +509,218 @@ private function initializeManagers(): void private function initializeWidgets(): void { $this->panels = new ItemList(Widget::class); - $halfHeight = (int)(($this->settings->height - 1) / 2); - $this->hierarchyPanel = new HierarchyPanel(height: $halfHeight); - $this->assetsPanel = new AssetsPanel(position: ['x' => 1, 'y' => $halfHeight + 1], height: $halfHeight); - $centralPanelWidth = $this->settings->width - 2 - (35 * 2); - - $this->inspectorPanel = new InspectorPanel(position: ['x' => ($centralPanelWidth + 35), 'y' => 1], height: $this->settings->height - 1); + $this->panelListModal = new PanelListModal(); + $this->hierarchyPanel = new HierarchyPanel( + hierarchy: $this->loadedScene?->hierarchy ?? [], + ); + $this->assetsPanel = new AssetsPanel( + assetsDirectoryPath: $this->assetsDirectoryPath, + ); + $this->mainPanel = new MainPanel(); + $this->consolePanel = new ConsolePanel(); + $this->inspectorPanel = new InspectorPanel(); $this->panels->add($this->hierarchyPanel); $this->panels->add($this->assetsPanel); + $this->panels->add($this->mainPanel); + $this->panels->add($this->consolePanel); $this->panels->add($this->inspectorPanel); + + $this->layoutPanels(); + $this->setFocusedPanel($this->mainPanel); + } + + private function handlePanelFocus(): void + { + if (!Input::isLeftMouseButtonDown()) { + return; + } + + $mouseEvent = Input::getMouseEvent(); + + if (!$mouseEvent) { + return; + } + + foreach ($this->panels as $panel) { + if ($panel->containsPoint($mouseEvent->x, $mouseEvent->y)) { + $this->setFocusedPanel($panel); + $panel->handleMouseClick($mouseEvent->x, $mouseEvent->y); + return; + } + } + } + + private function setFocusedPanel(Widget $panel): void + { + if ($this->focusedPanel === $panel) { + return; + } + + $context = $this->createFocusTargetContext(); + + $this->focusedPanel?->blur($context); + $this->focusedPanel = $panel; + $this->focusedPanel->focus($context); + } + + private function createFocusTargetContext(): FocusTargetContext + { + return new FocusTargetContext( + $this, + $this->gameSettings ?? new GameSettings(name: 'Untitled Game') + ); + } + + private function handlePanelKeyboardWorkflow(): void + { + if ($this->panelListModal->isVisible()) { + $this->handlePanelListModalInput(); + return; + } + + if (Input::getCurrentInput() === '!') { + $this->showPanelListModal(); + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::TAB)) { + $this->focusNextPanel(); + } + } + + private function refreshTerminalSize(bool $force = false): void + { + $terminalSize = get_max_terminal_size(); + $newWidth = $terminalSize['width'] ?? DEFAULT_TERMINAL_WIDTH; + $newHeight = $terminalSize['height'] ?? DEFAULT_TERMINAL_HEIGHT; + + if (!$force && $newWidth === $this->terminalWidth && $newHeight === $this->terminalHeight) { + return; + } + + $this->terminalWidth = $newWidth; + $this->terminalHeight = $newHeight; + + if (!isset($this->panels)) { + return; + } + + Console::init([ + 'width' => $this->terminalWidth, + 'height' => $this->terminalHeight, + ]); + + $this->layoutPanels(); + + if ($this->panelListModal->isVisible()) { + $this->shouldRefreshBackgroundUnderModal = true; + } + } + + private function layoutPanels(): void + { + $leftPanelWidth = min(35, max(12, intdiv(max($this->terminalWidth - 2, 1), 4))); + $rightPanelWidth = $leftPanelWidth; + $availableHeight = max(6, $this->terminalHeight - 1); + $topLeftHeight = max(3, intdiv($availableHeight, 2)); + $bottomLeftHeight = $availableHeight - $topLeftHeight; + $centralPanelX = $leftPanelWidth + 2; + $centralPanelWidth = max(12, $this->terminalWidth - ($leftPanelWidth * 2) - 2); + $consolePanelHeight = min(max(3, intdiv($availableHeight, 4)), $availableHeight - 3); + $mainPanelHeight = $availableHeight - $consolePanelHeight; + $inspectorPanelX = $centralPanelX + $centralPanelWidth + 1; + + $this->hierarchyPanel->setPosition(1, 1); + $this->hierarchyPanel->setDimensions($leftPanelWidth, $topLeftHeight); + + $this->assetsPanel->setPosition(1, $topLeftHeight + 1); + $this->assetsPanel->setDimensions($leftPanelWidth, $bottomLeftHeight); + + $this->mainPanel->setPosition($centralPanelX, 1); + $this->mainPanel->setDimensions($centralPanelWidth, $mainPanelHeight); + + $this->consolePanel->setPosition($centralPanelX, $mainPanelHeight + 1); + $this->consolePanel->setDimensions($centralPanelWidth, $consolePanelHeight); + + $this->inspectorPanel->setPosition($inspectorPanelX, 1); + $this->inspectorPanel->setDimensions($rightPanelWidth, $availableHeight); + + $this->panelListModal->syncLayout($this->terminalWidth, $this->terminalHeight); + } + + private function focusNextPanel(): void + { + $panels = $this->panels->toArray(); + $panelIndex = $this->getFocusedPanelIndex(); + $nextIndex = ($panelIndex + 1) % count($panels); + + /** @var Widget $panel */ + $panel = $panels[$nextIndex]; + $this->setFocusedPanel($panel); + } + + private function getFocusedPanelIndex(): int + { + foreach ($this->panels as $index => $panel) { + if ($panel === $this->focusedPanel) { + return $index; + } + } + + return 0; + } + + private function showPanelListModal(): void + { + $this->panelListModal->show( + $this->getPanelDisplayNames(), + $this->getFocusedPanelIndex() + ); + $this->panelListModal->syncLayout($this->terminalWidth, $this->terminalHeight); + $this->shouldRefreshBackgroundUnderModal = true; + } + + private function handlePanelListModalInput(): void + { + if (Input::isKeyDown(IO\Enumerations\KeyCode::ESCAPE)) { + $this->panelListModal->hide(); + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::UP)) { + $this->panelListModal->moveSelection(-1); + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::DOWN)) { + $this->panelListModal->moveSelection(1); + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::ENTER)) { + $selectedIndex = $this->panelListModal->getSelectedIndex(); + $this->panelListModal->hide(); + $panels = $this->panels->toArray(); + + if (!isset($panels[$selectedIndex])) { + return; + } + + /** @var Widget $panel */ + $panel = $panels[$selectedIndex]; + $this->setFocusedPanel($panel); + } + } + + private function getPanelDisplayNames(): array + { + $names = []; + + foreach ($this->panels as $panel) { + $names[] = $panel->getDisplayName(); + } + + return $names; } -} \ No newline at end of file +} diff --git a/src/Editor/IO/Input.php b/src/Editor/IO/Input.php index 29344a9..9da1d34 100644 --- a/src/Editor/IO/Input.php +++ b/src/Editor/IO/Input.php @@ -2,6 +2,7 @@ namespace Sendama\Console\Editor\IO; +use Atatusoft\Termutil\Events\MouseEvent; use Sendama\Console\Editor\IO\Enumerations\AxisName; use Sendama\Console\Editor\IO\Enumerations\KeyCode; @@ -89,6 +90,21 @@ public static function isKeyUp(KeyCode $keyCode): bool return InputManager::isKeyUp($keyCode); } + public static function getMouseEvent(): ?MouseEvent + { + return InputManager::getMouseEvent(); + } + + public static function getCurrentInput(): string + { + return InputManager::getCurrentInput(); + } + + public static function isLeftMouseButtonDown(): bool + { + return InputManager::isLeftMouseButtonDown(); + } + /** * Checks if the given button is pressed. * @@ -99,4 +115,4 @@ public static function isButtonDown(string $buttonName): bool { return InputManager::isButtonDown($buttonName); } -} \ No newline at end of file +} diff --git a/src/Editor/IO/InputManager.php b/src/Editor/IO/InputManager.php index 62a60d5..23d81f9 100644 --- a/src/Editor/IO/InputManager.php +++ b/src/Editor/IO/InputManager.php @@ -2,6 +2,7 @@ namespace Sendama\Console\Editor\IO; +use Atatusoft\Termutil\Events\MouseEvent; use Atatusoft\Termutil\Events\Interfaces\StaticObservableInterface; use Atatusoft\Termutil\Events\Traits\StaticObservableTrait; use Sendama\Console\Editor\Events\KeyboardEvent; @@ -23,6 +24,7 @@ class InputManager implements StaticObservableInterface private static string $previousKeyPress = ""; private static array $axes = []; private static array $buttons = []; + private static ?MouseEvent $mouseEvent = null; /** * Initializes the InputManager. @@ -32,6 +34,7 @@ class InputManager implements StaticObservableInterface public static function init(): void { self::$previousKeyPress = self::$keyPress = ""; + self::$mouseEvent = null; self::initializeObservers(); } @@ -88,11 +91,32 @@ public static function enableEcho(): void public static function handleInput(): void { self::$previousKeyPress = self::$keyPress; - self::$keyPress = fgets(STDIN) ?: ''; + self::$keyPress = self::normalizeInput(stream_get_contents(STDIN) ?: ''); + self::$mouseEvent = self::parseMouseEvent(self::$keyPress); + + if (self::$mouseEvent) { + self::notify(self::$mouseEvent); + return; + } self::notify(new KeyboardEvent(self::$keyPress)); } + public static function getMouseEvent(): ?MouseEvent + { + return self::$mouseEvent; + } + + public static function getCurrentInput(): string + { + return self::$keyPress; + } + + public static function isLeftMouseButtonDown(): bool + { + return self::$mouseEvent?->buttonIndex === 0; + } + /** * Takes the raw string value of a key press and returns it as a simplified string. * @@ -290,4 +314,30 @@ private static function findAxis(string $axisName): ?VirtualAxis { return array_filter(self::$axes, fn($axis) => $axis->getName() === $axisName)[0] ?? null; } -} \ No newline at end of file + + private static function parseMouseEvent(string $input): ?MouseEvent + { + if (preg_match('/\033\[<(\d+);(\d+);(\d+)([Mm])/', $input) !== 1) { + return null; + } + + return new MouseEvent($input); + } + + private static function normalizeInput(string $input): string + { + if ($input === '') { + return ''; + } + + if (preg_match('/\033\[<(\d+);(\d+);(\d+)([Mm])/', $input, $matches) === 1) { + return $matches[0]; + } + + if (str_starts_with($input, "\033")) { + return $input; + } + + return mb_substr($input, -1); + } +} diff --git a/src/Editor/SceneLoader.php b/src/Editor/SceneLoader.php new file mode 100644 index 0000000..dee30d1 --- /dev/null +++ b/src/Editor/SceneLoader.php @@ -0,0 +1,188 @@ +resolveActiveScenePath($sceneSettings); + + if (!$scenePath) { + return null; + } + + $sceneData = $this->loadSceneData($scenePath); + + return new SceneDTO( + name: basename($scenePath, '.scene.php'), + width: $sceneData['width'] ?? DEFAULT_TERMINAL_WIDTH, + height: $sceneData['height'] ?? DEFAULT_TERMINAL_HEIGHT, + environmentTileMapPath: $sceneData['environmentTileMapPath'] ?? 'Maps/example', + hierarchy: $sceneData['hierarchy'] ?? [], + ); + } + + public function resolveAssetsDirectory(): ?string + { + $candidates = [ + Path::join($this->workingDirectory, 'Assets'), + Path::join($this->workingDirectory, 'assets'), + ]; + + foreach ($candidates as $candidate) { + if (is_dir($candidate)) { + return $candidate; + } + } + + return null; + } + + public function resolveActiveScenePath(EditorSceneSettings $sceneSettings): ?string + { + $scenesDirectory = $this->resolveScenesDirectory(); + + $configuredScenePath = $this->resolveConfiguredScenePath($sceneSettings, $scenesDirectory); + + if ($configuredScenePath) { + return $configuredScenePath; + } + + return $this->resolveFirstScenePath($scenesDirectory); + } + + private function resolveScenesDirectory(): ?string + { + $assetsDirectory = $this->resolveAssetsDirectory(); + + if (!$assetsDirectory) { + return null; + } + + $candidates = [ + Path::join($assetsDirectory, 'Scenes'), + Path::join($assetsDirectory, 'scenes'), + ]; + + foreach ($candidates as $candidate) { + if (is_dir($candidate)) { + return $candidate; + } + } + + return null; + } + + private function resolveConfiguredScenePath( + EditorSceneSettings $sceneSettings, + ?string $scenesDirectory + ): ?string { + $configuredScene = $sceneSettings->loaded[$sceneSettings->active] ?? $sceneSettings->loaded[0] ?? null; + + if (!is_string($configuredScene) || trim($configuredScene) === '') { + return null; + } + + foreach ($this->buildScenePathCandidates($configuredScene, $scenesDirectory) as $candidate) { + if (is_file($candidate)) { + return $candidate; + } + } + + return null; + } + + private function resolveFirstScenePath(?string $scenesDirectory): ?string + { + if (!$scenesDirectory) { + return null; + } + + $sceneFiles = glob(Path::join($scenesDirectory, '*.scene.php')) ?: []; + sort($sceneFiles); + + return $sceneFiles[0] ?? null; + } + + private function buildScenePathCandidates(string $configuredScene, ?string $scenesDirectory): array + { + $configuredScene = trim($configuredScene); + $sceneVariants = [$configuredScene]; + + if (!str_ends_with($configuredScene, '.scene.php')) { + $sceneVariants[] = $configuredScene . '.scene.php'; + } + + $candidates = []; + + foreach ($sceneVariants as $sceneVariant) { + if ($this->isAbsolutePath($sceneVariant)) { + $candidates[] = Path::normalize($sceneVariant); + } + + $candidates[] = Path::join($this->workingDirectory, $sceneVariant); + + if ($scenesDirectory) { + $trimmedVariant = preg_replace('#^(Assets|assets)/(Scenes|scenes)/#', '', $sceneVariant) ?? $sceneVariant; + $trimmedVariant = preg_replace('#^(Scenes|scenes)/#', '', $trimmedVariant) ?? $trimmedVariant; + $candidates[] = Path::join($scenesDirectory, $trimmedVariant); + $candidates[] = Path::join($scenesDirectory, basename($sceneVariant)); + } + } + + return array_values(array_unique($candidates)); + } + + private function loadSceneData(string $scenePath): array + { + try { + $sceneData = require $scenePath; + + if (is_array($sceneData)) { + return $sceneData; + } + + Debug::warn("Scene metadata at {$scenePath} did not return an array."); + } catch (Throwable $throwable) { + Debug::warn("Failed to load scene metadata at {$scenePath}: {$throwable->getMessage()}"); + } + + return $this->extractSceneDataFromSource($scenePath); + } + + private function extractSceneDataFromSource(string $scenePath): array + { + $source = file_get_contents($scenePath); + + if ($source === false) { + return []; + } + + preg_match_all('/["\']name["\']\s*=>\s*["\']([^"\']+)["\']/', $source, $nameMatches); + + return [ + 'hierarchy' => array_map( + fn(string $name) => ['name' => $name], + $nameMatches[1] ?? [], + ) + ]; + } + + private function isAbsolutePath(string $path): bool + { + return str_starts_with($path, '/') + || preg_match('/^[A-Za-z]:[\/\\\\]/', $path) === 1; + } +} diff --git a/src/Editor/Widgets/AssetsPanel.php b/src/Editor/Widgets/AssetsPanel.php index 0e2665b..e4bbf4f 100644 --- a/src/Editor/Widgets/AssetsPanel.php +++ b/src/Editor/Widgets/AssetsPanel.php @@ -15,6 +15,9 @@ */ class AssetsPanel extends Widget { + protected array $assetEntries = []; + protected ?int $selectedIndex = null; + /** * AssetsPanel constructor. * @@ -31,39 +34,72 @@ public function __construct( ) { parent::__construct('Assets', '', $position, $width, $height); + $this->loadAssetEntries(); + $this->refreshContent(); + } + + public function handleMouseClick(int $x, int $y): void + { + if (!$this->containsPoint($x, $y)) { + return; + } + + $index = $y - $this->getContentAreaTop(); + + if (!isset($this->assetEntries[$index])) { + return; + } + + $this->selectedIndex = $index; + $this->refreshContent(); + } + + /** + * @inheritDoc + */ + public function update(): void + { + // TODO: Implement update() method. + } + private function loadAssetEntries(): void + { if (!$this->assetsDirectoryPath) { $this->assetsDirectoryPath = Path::getWorkingDirectoryAssetsPath(); } if (! file_exists($this->assetsDirectoryPath) ) { Debug::warn("Assets directory not found at {$this->assetsDirectoryPath}. Please create the directory and add your assets."); + $this->assetEntries = []; } else { $rootAssets = scandir($this->assetsDirectoryPath); if (false === $rootAssets) { Debug::error("Failed to read contents of assets directory at {$this->assetsDirectoryPath}."); + $this->assetEntries = []; } else { $rootAssets = array_slice($rootAssets, 2); - $content = []; + $entries = []; foreach ($rootAssets as $asset) { - $contentLine = " $asset"; - if (is_dir(Path::join($this->assetsDirectoryPath, $asset))) { - $contentLine = "► $asset"; - } - - $content[] = $contentLine; + $entries[] = [ + 'name' => $asset, + 'isDirectory' => is_dir(Path::join($this->assetsDirectoryPath, $asset)), + ]; } - $this->content = $content; + $this->assetEntries = $entries; } } } - /** - * @inheritDoc - */ - public function update(): void + private function refreshContent(): void { - // TODO: Implement update() method. + $this->content = array_map(function (array $assetEntry, int $index) { + $name = $assetEntry['name'] ?? 'Unnamed Asset'; + $icon = $index === $this->selectedIndex + ? '>' + : ($assetEntry['isDirectory'] ?? false ? '►' : ' '); + + return "$icon $name"; + }, $this->assetEntries, array_keys($this->assetEntries)); } -} \ No newline at end of file +} diff --git a/src/Editor/Widgets/ConsolePanel.php b/src/Editor/Widgets/ConsolePanel.php new file mode 100644 index 0000000..365d944 --- /dev/null +++ b/src/Editor/Widgets/ConsolePanel.php @@ -0,0 +1,35 @@ + 37, 'y' => 22], + int $width = 96, + int $height = 8 + ) + { + parent::__construct('Console', '', $position, $width, $height); + $this->update(); + } + + public function append(string $message): void + { + $this->messages[] = $message; + $this->update(); + } + + public function clear(): void + { + $this->messages = []; + $this->update(); + } + + public function update(): void + { + $this->content = array_slice($this->messages, -$this->innerHeight); + } +} diff --git a/src/Editor/Widgets/HierarchyPanel.php b/src/Editor/Widgets/HierarchyPanel.php index c300804..1a43cb7 100644 --- a/src/Editor/Widgets/HierarchyPanel.php +++ b/src/Editor/Widgets/HierarchyPanel.php @@ -16,42 +16,57 @@ class HierarchyPanel extends Widget implements ObservableInterface { use ObservableTrait; - public array $hierarchy { - get { - return $this->hierarchy; - } - - set { - $this->hierarchy = $value; - $this->notify(new EditorEvent(EventType::HIERARCHY_CHANGED->value, $this, )); - } - } + protected array $hierarchy = []; + protected ?int $selectedIndex = null; public function __construct( array $position = ['x' => 1, 'y' => 1], int $width = 35, int $height = 14, - array $hierarchy = [ - [ - "name" => "Level Manager" - ], - [ - "name" => "Game Object" - ] - ] + array $hierarchy = [] ) { $this->initializeObservers(); - $this->hierarchy = $hierarchy; parent::__construct('Hierarchy', '', $position, $width, $height); + $this->setHierarchy($hierarchy); + } - // Bind hierarchy to content for display - $this->content = array_map(function (array $item) { - $objectName = $item['name'] ?? 'Unnamed Object'; - $icon = "►"; // TODO: Determine icon based on object type - // TODO: Add indentation based on hierarchy level - return "$icon $objectName"; - }, $this->hierarchy); + public function getHierarchy(): array + { + return $this->hierarchy; + } + + public function setHierarchy(array $hierarchy): void + { + $this->hierarchy = $hierarchy; + $this->refreshContent(); + + $this->notify(new EditorEvent(EventType::HIERARCHY_CHANGED->value, $this)); + } + + public function getSelectedHierarchyObject(): ?array + { + if ($this->selectedIndex === null) { + return null; + } + + return $this->hierarchy[$this->selectedIndex] ?? null; + } + + public function handleMouseClick(int $x, int $y): void + { + if (!$this->containsPoint($x, $y)) { + return; + } + + $index = $y - $this->getContentAreaTop(); + + if (!isset($this->hierarchy[$index])) { + return; + } + + $this->selectedIndex = $index; + $this->refreshContent(); } /** @@ -61,4 +76,13 @@ public function update(): void { // TODO: Implement update() method. } -} \ No newline at end of file + + private function refreshContent(): void + { + $this->content = array_map(function (array $item, int $index) { + $objectName = $item['name'] ?? 'Unnamed Object'; + $icon = $index === $this->selectedIndex ? '>' : '►'; + return "$icon $objectName"; + }, $this->hierarchy, array_keys($this->hierarchy)); + } +} diff --git a/src/Editor/Widgets/MainPanel.php b/src/Editor/Widgets/MainPanel.php new file mode 100644 index 0000000..78b2d24 --- /dev/null +++ b/src/Editor/Widgets/MainPanel.php @@ -0,0 +1,129 @@ + 37, 'y' => 1], + int $width = 96, + int $height = 21 + ) + { + parent::__construct('', '', $position, $width, $height); + + $this->refreshContent(); + } + + public function getActiveTab(): string + { + return self::TAB_TITLES[$this->activeTabIndex]; + } + + public function activateNextTab(): void + { + $this->activeTabIndex = ($this->activeTabIndex + 1) % count(self::TAB_TITLES); + $this->refreshContent(); + } + + public function activatePreviousTab(): void + { + $this->activeTabIndex = ($this->activeTabIndex - 1 + count(self::TAB_TITLES)) % count(self::TAB_TITLES); + $this->refreshContent(); + } + + public function selectTab(string $tabTitle): void + { + $tabIndex = array_search($tabTitle, self::TAB_TITLES, true); + + if ($tabIndex === false) { + return; + } + + $this->activeTabIndex = $tabIndex; + $this->refreshContent(); + } + + public function update(): void + { + if ($this->hasFocus()) { + if (Input::isKeyDown(KeyCode::RIGHT)) { + $this->activateNextTab(); + return; + } + + if (Input::isKeyDown(KeyCode::LEFT)) { + $this->activatePreviousTab(); + return; + } + } + + $this->refreshContent(); + } + + public function handleMouseClick(int $x, int $y): void + { + if (!$this->containsPoint($x, $y) || $y !== $this->getContentAreaTop()) { + return; + } + + $currentX = $this->getContentAreaLeft(); + + foreach (self::TAB_TITLES as $index => $tabTitle) { + if ($index > 0) { + $currentX += 2; + } + + $tabStart = $currentX; + $tabEnd = $tabStart + strlen($tabTitle) - 1; + + if ($x >= $tabStart && $x <= $tabEnd) { + $this->activeTabIndex = $index; + $this->refreshContent(); + return; + } + + $currentX = $tabEnd + 1; + } + } + + private function refreshContent(): void + { + $tabsLine = ''; + $activeTabOffset = 0; + + foreach (self::TAB_TITLES as $index => $tabTitle) { + if ($index > 0) { + $tabsLine .= ' '; + } + + if ($index === $this->activeTabIndex) { + $activeTabOffset = strlen($tabsLine); + } + + $tabsLine .= $tabTitle; + } + + $dividerWidth = max(0, $this->innerWidth - 2); + $dividerLine = str_repeat('-', $dividerWidth); + $activeTabTitle = self::TAB_TITLES[$this->activeTabIndex]; + + if ($dividerWidth > 0) { + $dividerLine = substr_replace( + $dividerLine, + str_repeat('=', strlen($activeTabTitle)), + $activeTabOffset, + strlen($activeTabTitle) + ); + } + + $this->content = [$tabsLine, $dividerLine]; + } +} diff --git a/src/Editor/Widgets/PanelListModal.php b/src/Editor/Widgets/PanelListModal.php new file mode 100644 index 0000000..5cb48d6 --- /dev/null +++ b/src/Editor/Widgets/PanelListModal.php @@ -0,0 +1,126 @@ + 1, 'y' => 1], + width: 28, + height: 7, + ); + } + + public function show(array $panelNames, int $selectedIndex = 0): void + { + $this->panelNames = array_values($panelNames); + $panelCount = count($this->panelNames); + $this->selectedIndex = $panelCount > 0 + ? max(0, min($selectedIndex, $panelCount - 1)) + : 0; + $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 getSelectedIndex(): int + { + return $this->selectedIndex; + } + + public function isDirty(): bool + { + return $this->isDirty; + } + + public function markClean(): void + { + $this->isDirty = false; + } + + public function moveSelection(int $offset): void + { + $panelCount = count($this->panelNames); + + if ($panelCount === 0) { + return; + } + + $this->selectedIndex = ($this->selectedIndex + $offset + $panelCount) % $panelCount; + $this->refreshContent(); + $this->markDirty(); + } + + public function syncLayout(int $terminalWidth, int $terminalHeight): void + { + $longestNameLength = 0; + + foreach ($this->panelNames as $panelName) { + $longestNameLength = max($longestNameLength, strlen($panelName)); + } + + $desiredWidth = max( + strlen($this->title) + 4, + strlen($this->help) + 4, + $longestNameLength + 6 + ); + $modalWidth = min(max(18, $desiredWidth), max(18, $terminalWidth - 2)); + $modalHeight = min(max(4, count($this->panelNames) + 2), max(4, $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 refreshContent(): void + { + $this->content = array_map(function (string $panelName, int $index) { + $icon = $index === $this->selectedIndex ? '>' : ' '; + return "$icon $panelName"; + }, $this->panelNames, array_keys($this->panelNames)); + } + + private function markDirty(): void + { + $this->isDirty = true; + } +} diff --git a/src/Editor/Widgets/Widget.php b/src/Editor/Widgets/Widget.php index 72b0fac..b0c3773 100644 --- a/src/Editor/Widgets/Widget.php +++ b/src/Editor/Widgets/Widget.php @@ -2,6 +2,7 @@ namespace Sendama\Console\Editor\Widgets; +use Atatusoft\Termutil\IO\Enumerations\Color; use Atatusoft\Termutil\UI\Windows\Window; use Sendama\Console\Editor\FocusTargetContext; use Sendama\Console\Editor\Interfaces\FocusableInterface; @@ -20,7 +21,7 @@ abstract class Widget extends Window implements FocusableInterface } set { - $this->position["y"] = $value; + $this->position["x"] = $value; } } /** @@ -36,6 +37,8 @@ abstract class Widget extends Window implements FocusableInterface } } protected(set) bool $isEnabled = true; + protected bool $hasFocus = false; + protected Color $focusBorderColor = Color::LIGHT_CYAN; /** * Enables the widget. @@ -57,12 +60,63 @@ public function disable(): void $this->isEnabled = false; } + public function hasFocus(): bool + { + return $this->hasFocus; + } + + public function containsPoint(int $x, int $y): bool + { + $left = $this->position['x'] ?? 0; + $top = $this->position['y'] ?? 0; + $right = $left + $this->width - 1; + $bottom = $top + $this->height - 1; + + return $x >= $left && $x <= $right && $y >= $top && $y <= $bottom; + } + + public function setPosition(int $x, int $y): void + { + $this->position = ['x' => $x, 'y' => $y]; + } + + public function setDimensions(int $width, int $height): void + { + $this->width = max(3, $width); + $this->height = max(3, $height); + } + + public function getDisplayName(): string + { + if (!empty($this->title)) { + return $this->title; + } + + $className = substr(strrchr(static::class, '\\') ?: static::class, 1) ?: static::class; + + return preg_replace('/Panel$/', '', $className) ?: $className; + } + + public function handleMouseClick(int $x, int $y): void + { + } + + protected function getContentAreaTop(): int + { + return ($this->position['y'] ?? 0) + 1; + } + + protected function getContentAreaLeft(): int + { + return ($this->position['x'] ?? 0) + 1 + $this->padding->leftPadding; + } + /** * @inheritDoc */ public function focus(FocusTargetContext $context): void { - // TODO: Implement focus() method. + $this->hasFocus = true; } /** @@ -70,11 +124,83 @@ public function focus(FocusTargetContext $context): void */ public function blur(FocusTargetContext $context): void { - // TODO: Implement blur() method. + $this->hasFocus = false; + } + + public function renderAt(?int $x = null, ?int $y = null): void + { + $position = $this->position; + $positionX = $position["x"] ?? $position[0]; + $positionY = $position["y"] ?? $position[1]; + + $leftMargin = $positionX + ($x ?? 0); + $topMargin = $positionY + ($y ?? 0); + $contentColor = $this->foregroundColor; + + $this->foregroundColor = null; + $topBorder = $this->topBorder; + $linesOfContent = $this->linesOfContent; + $bottomBorder = $this->bottomBorder; + $this->foregroundColor = $contentColor; + + if (!$linesOfContent) { + $linesOfContent = ['']; + } + + $this->cursor->moveTo($leftMargin, $topMargin); + echo $this->decorateBorderLine($topBorder, $contentColor); + + foreach ($linesOfContent as $index => $line) { + $this->cursor->moveTo($leftMargin, $topMargin + $index + 1); + echo $this->decorateContentLine($line, $contentColor); + } + + $this->cursor->moveTo($leftMargin, $topMargin + count($linesOfContent) + 1); + echo $this->decorateBorderLine($bottomBorder, $contentColor); + } + + private function decorateBorderLine(string $line, ?Color $contentColor): string + { + $visibleLine = mb_substr($line, 0, $this->width); + $borderColor = $this->hasFocus ? $this->focusBorderColor : $contentColor; + + return $this->wrapWithColor($visibleLine, $borderColor); + } + + private function decorateContentLine(string $line, ?Color $contentColor): string + { + $visibleLine = mb_substr($line, 0, $this->width); + + if (!$this->hasFocus) { + return $this->wrapWithColor($visibleLine, $contentColor); + } + + $visibleLength = mb_strlen($visibleLine); + + if ($visibleLength <= 1) { + return $this->wrapWithColor($visibleLine, $this->focusBorderColor); + } + + $leftBorder = mb_substr($visibleLine, 0, 1); + $middle = $visibleLength > 2 ? mb_substr($visibleLine, 1, $visibleLength - 2) : ''; + $rightBorder = mb_substr($visibleLine, -1); + + return $this->wrapWithColor($leftBorder, $this->focusBorderColor) + . $this->wrapWithColor($middle, $contentColor) + . $this->wrapWithColor($rightBorder, $this->focusBorderColor); + } + + private function wrapWithColor(string $content, ?Color $color): string + { + if ($content === '' || $color === null) { + return $content; + } + + return $color->value . $content . Color::RESET->value; } /** * @return void */ public abstract function update(): void; -} \ No newline at end of file +} diff --git a/tests/Unit/MainPanelTest.php b/tests/Unit/MainPanelTest.php new file mode 100644 index 0000000..164e001 --- /dev/null +++ b/tests/Unit/MainPanelTest.php @@ -0,0 +1,35 @@ +getActiveTab())->toBe('Scene'); + + $panel->activateNextTab(); + expect($panel->getActiveTab())->toBe('Game'); + + $panel->activateNextTab(); + expect($panel->getActiveTab())->toBe('Sprite'); + + $panel->activateNextTab(); + expect($panel->getActiveTab())->toBe('Scene'); +}); + +test('main panel cycles backward through tabs', function () { + $panel = new MainPanel(width: 60, height: 12); + + $panel->activatePreviousTab(); + + expect($panel->getActiveTab())->toBe('Sprite'); +}); + +test('main panel highlights the active tab in the divider', function () { + $panel = new MainPanel(width: 60, height: 12); + + $panel->selectTab('Sprite'); + + expect($panel->content[0])->toContain('Scene Game Sprite'); + expect($panel->content[1])->toContain('======'); +}); diff --git a/tests/Unit/SceneLoaderTest.php b/tests/Unit/SceneLoaderTest.php new file mode 100644 index 0000000..a46b6a0 --- /dev/null +++ b/tests/Unit/SceneLoaderTest.php @@ -0,0 +1,50 @@ + 120, + 'height' => 40, + 'hierarchy' => [ + ['name' => 'Game Manager'], + ['name' => 'Player'], + ], +]; +PHP + ); + + $loader = new SceneLoader($workspace); + $scene = $loader->load(new EditorSceneSettings(active: 0, loaded: ['level01'])); + + expect($scene)->not->toBeNull(); + expect($scene->name)->toBe('level01'); + expect($scene->hierarchy)->toHaveCount(2); + expect($scene->hierarchy[1]['name'])->toBe('Player'); +}); + +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); + + file_put_contents($workspace . '/assets/Scenes/alpha.scene.php', " [['name' => 'Alpha']]];"); + file_put_contents($workspace . '/assets/Scenes/beta.scene.php', " [['name' => 'Beta']]];"); + + $loader = new SceneLoader($workspace); + $scene = $loader->load(new EditorSceneSettings()); + + expect($scene)->not->toBeNull(); + expect($scene->name)->toBe('alpha'); + expect($scene->hierarchy[0]['name'])->toBe('Alpha'); +}); From 9e75fe2b19e37b7f82a63ca9b377f3df526d546f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 11 Mar 2026 19:55:22 +0200 Subject: [PATCH 3/6] feat(editor): enhance AssetsPanel and HierarchyPanel with improved selection and navigation features --- src/Editor/DTOs/SceneDTO.php | 5 +- src/Editor/Editor.php | 37 +++ src/Editor/IO/Enumerations/KeyCode.php | 1 + src/Editor/IO/InputManager.php | 1 + src/Editor/SceneLoader.php | 28 +- src/Editor/Widgets/AssetsPanel.php | 394 ++++++++++++++++++++++--- src/Editor/Widgets/HierarchyPanel.php | 373 ++++++++++++++++++++++- src/Editor/Widgets/InspectorPanel.php | 22 +- src/Editor/Widgets/Widget.php | 17 +- tests/Unit/SceneLoaderTest.php | 42 +++ 10 files changed, 850 insertions(+), 70 deletions(-) diff --git a/src/Editor/DTOs/SceneDTO.php b/src/Editor/DTOs/SceneDTO.php index 88429ab..7a1e10c 100644 --- a/src/Editor/DTOs/SceneDTO.php +++ b/src/Editor/DTOs/SceneDTO.php @@ -12,6 +12,7 @@ public function __construct( public int $width = DEFAULT_TERMINAL_WIDTH, public int $height = DEFAULT_TERMINAL_HEIGHT, public string $environmentTileMapPath = "Maps/example", + public bool $isDirty = false, public array $hierarchy = [], ) { @@ -24,6 +25,7 @@ public function __serialize(): array "width" => $this->width, "height" => $this->height, "environmentTileMapPath" => $this->environmentTileMapPath, + "isDirty" => $this->isDirty, "hierarchy" => $this->hierarchy, ]; } @@ -34,6 +36,7 @@ public function __unserialize(array $data): void $this->width = $data['width'] ?? DEFAULT_TERMINAL_WIDTH; $this->height = $data['height'] ?? DEFAULT_TERMINAL_HEIGHT; $this->environmentTileMapPath = $data['environmentTileMapPath'] ?? "Maps/example"; + $this->isDirty = $data['isDirty'] ?? false; $this->hierarchy = $data['hierarchy'] ?? []; } -} \ No newline at end of file +} diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php index 2ecbfa9..3168e6c 100644 --- a/src/Editor/Editor.php +++ b/src/Editor/Editor.php @@ -353,10 +353,17 @@ private function update(): void $this->editorState->update(); $this->handlePanelKeyboardWorkflow(); + if ($this->panelListModal->isVisible()) { + $this->notify(new EditorEvent(EventType::EDITOR_UPDATED->value, $this)); + return; + } + foreach ($this->panels as $panel) { $panel->update(); } + $this->synchronizeInspectorPanel(); + $this->notify(new EditorEvent(EventType::EDITOR_UPDATED->value, $this)); } @@ -511,6 +518,8 @@ private function initializeWidgets(): void $this->panels = new ItemList(Widget::class); $this->panelListModal = new PanelListModal(); $this->hierarchyPanel = new HierarchyPanel( + sceneName: $this->loadedScene?->name ?? 'Scene', + isSceneDirty: $this->loadedScene?->isDirty ?? false, hierarchy: $this->loadedScene?->hierarchy ?? [], ); $this->assetsPanel = new AssetsPanel( @@ -584,6 +593,11 @@ private function handlePanelKeyboardWorkflow(): void return; } + if (Input::isKeyDown(IO\Enumerations\KeyCode::SHIFT_TAB)) { + $this->focusPreviousPanel(); + return; + } + if (Input::isKeyDown(IO\Enumerations\KeyCode::TAB)) { $this->focusNextPanel(); } @@ -660,6 +674,17 @@ private function focusNextPanel(): void $this->setFocusedPanel($panel); } + private function focusPreviousPanel(): void + { + $panels = $this->panels->toArray(); + $panelIndex = $this->getFocusedPanelIndex(); + $previousIndex = ($panelIndex - 1 + count($panels)) % count($panels); + + /** @var Widget $panel */ + $panel = $panels[$previousIndex]; + $this->setFocusedPanel($panel); + } + private function getFocusedPanelIndex(): int { foreach ($this->panels as $index => $panel) { @@ -713,6 +738,18 @@ private function handlePanelListModalInput(): void } } + private function synchronizeInspectorPanel(): void + { + $selectedItem = $this->hierarchyPanel->consumeInspectionRequest() + ?? $this->assetsPanel->consumeInspectionRequest(); + + if ($selectedItem === null) { + return; + } + + $this->inspectorPanel->inspectTarget($selectedItem); + } + private function getPanelDisplayNames(): array { $names = []; diff --git a/src/Editor/IO/Enumerations/KeyCode.php b/src/Editor/IO/Enumerations/KeyCode.php index 8f2e512..332cd47 100644 --- a/src/Editor/IO/Enumerations/KeyCode.php +++ b/src/Editor/IO/Enumerations/KeyCode.php @@ -12,6 +12,7 @@ enum KeyCode: string case ENTER = 'enter'; case SPACE = 'space'; case TAB = 'tab'; + case SHIFT_TAB = 'shift_tab'; case BACKSPACE = 'backspace'; case ESCAPE = 'escape'; case DELETE = 'delete'; diff --git a/src/Editor/IO/InputManager.php b/src/Editor/IO/InputManager.php index 23d81f9..2162d43 100644 --- a/src/Editor/IO/InputManager.php +++ b/src/Editor/IO/InputManager.php @@ -134,6 +134,7 @@ private static function getKey(?string $keyPress): string "\033[B" => KeyCode::DOWN->value, "\033[C" => KeyCode::RIGHT->value, "\033[D" => KeyCode::LEFT->value, + "\033[Z", "\033[1;2Z" => KeyCode::SHIFT_TAB->value, "\n" => KeyCode::ENTER->value, " " => KeyCode::SPACE->value, "\010", "\177" => KeyCode::BACKSPACE->value, diff --git a/src/Editor/SceneLoader.php b/src/Editor/SceneLoader.php index dee30d1..a917642 100644 --- a/src/Editor/SceneLoader.php +++ b/src/Editor/SceneLoader.php @@ -30,6 +30,7 @@ public function load(EditorSceneSettings $sceneSettings): ?SceneDTO width: $sceneData['width'] ?? DEFAULT_TERMINAL_WIDTH, height: $sceneData['height'] ?? DEFAULT_TERMINAL_HEIGHT, environmentTileMapPath: $sceneData['environmentTileMapPath'] ?? 'Maps/example', + isDirty: $sceneData['isDirty'] ?? false, hierarchy: $sceneData['hierarchy'] ?? [], ); } @@ -171,12 +172,31 @@ private function extractSceneDataFromSource(string $scenePath): array } preg_match_all('/["\']name["\']\s*=>\s*["\']([^"\']+)["\']/', $source, $nameMatches); + preg_match_all( + '/["\']type["\']\s*=>\s*(?:"([^"]+)"|\'([^\']+)\'|([A-Za-z_\\\\][A-Za-z0-9_\\\\]*::class))/', + $source, + $typeMatches, + PREG_SET_ORDER + ); + + $names = $nameMatches[1] ?? []; + $types = array_map(function (array $match) { + return $match[1] ?: $match[2] ?: $match[3] ?: null; + }, $typeMatches); + $hierarchy = []; + + foreach ($names as $index => $name) { + $entry = ['name' => $name]; + + if (isset($types[$index]) && is_string($types[$index]) && $types[$index] !== '') { + $entry['type'] = $types[$index]; + } + + $hierarchy[] = $entry; + } return [ - 'hierarchy' => array_map( - fn(string $name) => ['name' => $name], - $nameMatches[1] ?? [], - ) + 'hierarchy' => $hierarchy, ]; } diff --git a/src/Editor/Widgets/AssetsPanel.php b/src/Editor/Widgets/AssetsPanel.php index e4bbf4f..fe25d6d 100644 --- a/src/Editor/Widgets/AssetsPanel.php +++ b/src/Editor/Widgets/AssetsPanel.php @@ -2,30 +2,31 @@ namespace Sendama\Console\Editor\Widgets; +use Atatusoft\Termutil\IO\Enumerations\Color; use Sendama\Console\Debug\Debug; +use Sendama\Console\Editor\IO\Enumerations\KeyCode; +use Sendama\Console\Editor\IO\Input; use Sendama\Console\Util\Path; /** * AssetsPanel class. * - * This panel is responsible for displaying the list of assets available in the game, such as scripts, textures, and - * other resources. It allows users to browse and manage their assets within the editor. - * - * @package Sendama\Console\Editor\Widgets + * This panel is responsible for displaying the assets in the current project's Assets directory. */ class AssetsPanel extends Widget { - protected array $assetEntries = []; - protected ?int $selectedIndex = null; - - /** - * AssetsPanel constructor. - * - * @param array $position The position of the panel in the editor (default: ['x' => 1, 'y' => 15]). - * @param int $width The width of the panel (default: 35). - * @param int $height The height of the panel (default: 14). - * @param string|null $assetsDirectoryPath The path to the assets directory (optional). - */ + 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"; + + protected array $assetTree = []; + protected array $visibleAssets = []; + protected array $expandedPaths = []; + protected ?string $selectedPath = null; + protected ?array $pendingInspectionTarget = null; + public function __construct( array $position = ['x' => 1, 'y' => 15], int $width = 35, @@ -38,6 +39,100 @@ public function __construct( $this->refreshContent(); } + public function getSelectedAssetEntry(): ?array + { + return $this->getSelectedVisibleAsset()['item'] ?? null; + } + + public function moveSelection(int $offset): void + { + if (!$this->visibleAssets) { + return; + } + + $selectedIndex = $this->getSelectedVisibleIndex() ?? 0; + $nextIndex = max(0, min($selectedIndex + $offset, count($this->visibleAssets) - 1)); + $this->selectedPath = $this->visibleAssets[$nextIndex]['path'] ?? $this->selectedPath; + $this->refreshContent(); + } + + public function expandSelection(): void + { + $selectedAsset = $this->getSelectedVisibleAsset(); + + if (!$selectedAsset || !($selectedAsset['isDirectory'] ?? false)) { + return; + } + + if (!($selectedAsset['isExpanded'] ?? false)) { + $this->expandedPaths[$selectedAsset['path']] = true; + $this->refreshContent(); + return; + } + + $selectedDepth = $selectedAsset['depth']; + $selectedPath = $selectedAsset['path']; + + foreach ($this->visibleAssets as $entry) { + if ( + str_starts_with($entry['path'], $selectedPath . '.') + && $entry['depth'] === $selectedDepth + 1 + ) { + $this->selectedPath = $entry['path']; + $this->refreshContent(); + return; + } + } + } + + public function collapseSelection(): void + { + $selectedAsset = $this->getSelectedVisibleAsset(); + + if (!$selectedAsset) { + return; + } + + if (($selectedAsset['isDirectory'] ?? false) && ($selectedAsset['isExpanded'] ?? false)) { + unset($this->expandedPaths[$selectedAsset['path']]); + $this->refreshContent(); + return; + } + + $parentPath = $this->getParentPath($selectedAsset['path']); + + if ($parentPath === null) { + return; + } + + $this->selectedPath = $parentPath; + $this->refreshContent(); + } + + public function activateSelection(): void + { + $selectedAsset = $this->getSelectedAssetEntry(); + + if ($selectedAsset === null) { + return; + } + + $this->pendingInspectionTarget = [ + 'context' => 'asset', + 'name' => $selectedAsset['name'] ?? 'Unnamed Asset', + 'type' => ($selectedAsset['isDirectory'] ?? false) ? 'Folder' : 'File', + 'value' => $selectedAsset, + ]; + } + + public function consumeInspectionRequest(): ?array + { + $pendingInspectionTarget = $this->pendingInspectionTarget; + $this->pendingInspectionTarget = null; + + return $pendingInspectionTarget; + } + public function handleMouseClick(int $x, int $y): void { if (!$this->containsPoint($x, $y)) { @@ -46,20 +141,75 @@ public function handleMouseClick(int $x, int $y): void $index = $y - $this->getContentAreaTop(); - if (!isset($this->assetEntries[$index])) { + if (!isset($this->visibleAssets[$index])) { return; } - $this->selectedIndex = $index; + $this->selectedPath = $this->visibleAssets[$index]['path'] ?? $this->selectedPath; $this->refreshContent(); + $this->activateSelection(); } - /** - * @inheritDoc - */ public function update(): void { - // TODO: Implement update() method. + if (!$this->hasFocus()) { + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $this->moveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->moveSelection(1); + return; + } + + if (Input::isKeyDown(KeyCode::RIGHT)) { + $this->expandSelection(); + return; + } + + if (Input::isKeyDown(KeyCode::LEFT)) { + $this->collapseSelection(); + return; + } + + if (Input::isKeyDown(KeyCode::ENTER)) { + $this->activateSelection(); + } + } + + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string + { + $selectedVisibleIndex = $this->getSelectedVisibleIndex(); + $selectedLineIndex = $selectedVisibleIndex === null + ? null + : $this->padding->topPadding + $selectedVisibleIndex; + + if ($lineIndex !== $selectedLineIndex) { + return parent::decorateContentLine($line, $contentColor, $lineIndex); + } + + $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; + $selectionSequence = $this->hasFocus() + ? self::SELECTED_ROW_FOCUSED_SEQUENCE + : self::SELECTED_ROW_SEQUENCE; + + return $this->wrapWithColor($leftBorder, $borderColor) + . $this->wrapWithSequence($middle, $selectionSequence) + . $this->wrapWithColor($rightBorder, $borderColor); } private function loadAssetEntries(): void @@ -68,38 +218,190 @@ private function loadAssetEntries(): void $this->assetsDirectoryPath = Path::getWorkingDirectoryAssetsPath(); } - if (! file_exists($this->assetsDirectoryPath) ) { + if (!$this->assetsDirectoryPath || !is_dir($this->assetsDirectoryPath)) { Debug::warn("Assets directory not found at {$this->assetsDirectoryPath}. Please create the directory and add your assets."); - $this->assetEntries = []; - } else { - $rootAssets = scandir($this->assetsDirectoryPath); - - if (false === $rootAssets) { - Debug::error("Failed to read contents of assets directory at {$this->assetsDirectoryPath}."); - $this->assetEntries = []; - } else { - $rootAssets = array_slice($rootAssets, 2); - $entries = []; - foreach ($rootAssets as $asset) { - $entries[] = [ - 'name' => $asset, - 'isDirectory' => is_dir(Path::join($this->assetsDirectoryPath, $asset)), - ]; - } - $this->assetEntries = $entries; + $this->assetTree = []; + return; + } + + $this->assetTree = $this->buildAssetTree($this->assetsDirectoryPath); + } + + private function buildAssetTree(string $directory): array + { + $entries = scandir($directory); + + if ($entries === false) { + Debug::error("Failed to read contents of assets directory at {$directory}."); + return []; + } + + $assetEntries = []; + + foreach ($entries as $entryName) { + if ($entryName === '.' || $entryName === '..') { + continue; } + + $entryPath = Path::join($directory, $entryName); + $isDirectory = is_dir($entryPath); + + $assetEntries[] = [ + 'name' => $entryName, + 'path' => $entryPath, + 'relativePath' => $this->buildRelativePath($entryPath), + 'isDirectory' => $isDirectory, + 'children' => $isDirectory ? $this->buildAssetTree($entryPath) : [], + ]; } + + usort($assetEntries, function (array $left, array $right) { + if (($left['isDirectory'] ?? false) !== ($right['isDirectory'] ?? false)) { + return ($left['isDirectory'] ?? false) ? -1 : 1; + } + + return strcasecmp($left['name'] ?? '', $right['name'] ?? ''); + }); + + return $assetEntries; + } + + private function buildRelativePath(string $path): string + { + if (!$this->assetsDirectoryPath) { + return basename($path); + } + + $relativePath = substr($path, strlen($this->assetsDirectoryPath)); + + return ltrim($relativePath ?: basename($path), DIRECTORY_SEPARATOR); } private function refreshContent(): void { - $this->content = array_map(function (array $assetEntry, int $index) { - $name = $assetEntry['name'] ?? 'Unnamed Asset'; - $icon = $index === $this->selectedIndex - ? '>' - : ($assetEntry['isDirectory'] ?? false ? '►' : ' '); + $this->visibleAssets = $this->buildVisibleAssets($this->assetTree); + $this->syncSelectedPath(); + $this->content = array_map( + fn(array $entry) => $this->formatVisibleAssetEntry($entry), + $this->visibleAssets + ); + } + + private function buildVisibleAssets(array $items, int $depth = 0, string $parentPath = ''): array + { + $visibleAssets = []; + + foreach (array_values($items) as $index => $item) { + if (!is_array($item)) { + continue; + } + + $path = $parentPath === '' ? (string)$index : $parentPath . '.' . $index; + $isDirectory = (bool)($item['isDirectory'] ?? false); + $isExpanded = $isDirectory && isset($this->expandedPaths[$path]); + + $visibleAssets[] = [ + 'path' => $path, + 'item' => $item, + 'depth' => $depth, + 'isDirectory' => $isDirectory, + 'isExpanded' => $isExpanded, + ]; + + if ($isExpanded) { + $visibleAssets = [ + ...$visibleAssets, + ...$this->buildVisibleAssets($this->getChildItems($item), $depth + 1, $path), + ]; + } + } + + return $visibleAssets; + } + + private function syncSelectedPath(): void + { + if ($this->selectedPath !== null && $this->findVisibleIndexByPath($this->selectedPath) !== null) { + return; + } + + $candidatePath = $this->selectedPath; + + while ($candidatePath !== null) { + $candidatePath = $this->getParentPath($candidatePath); + + if ($candidatePath !== null && $this->findVisibleIndexByPath($candidatePath) !== null) { + $this->selectedPath = $candidatePath; + return; + } + } + + $this->selectedPath = $this->visibleAssets[0]['path'] ?? null; + } + + private function getSelectedVisibleAsset(): ?array + { + $selectedVisibleIndex = $this->getSelectedVisibleIndex(); + + if ($selectedVisibleIndex === null) { + return null; + } + + return $this->visibleAssets[$selectedVisibleIndex] ?? null; + } + + private function getSelectedVisibleIndex(): ?int + { + return $this->findVisibleIndexByPath($this->selectedPath); + } + + private function findVisibleIndexByPath(?string $path): ?int + { + if ($path === null) { + return null; + } + + foreach ($this->visibleAssets as $index => $entry) { + if (($entry['path'] ?? null) === $path) { + return $index; + } + } + + return null; + } + + private function formatVisibleAssetEntry(array $entry): string + { + $icon = match (true) { + ($entry['isDirectory'] ?? false) && ($entry['isExpanded'] ?? false) => self::EXPANDED_ICON, + ($entry['isDirectory'] ?? false) => self::COLLAPSED_ICON, + default => self::LEAF_ICON, + }; + $name = $entry['item']['name'] ?? 'Unnamed Asset'; + $indentation = str_repeat(' ', (int)($entry['depth'] ?? 0)); + + return $indentation . $icon . ' ' . $name; + } + + private function getChildItems(array $item): array + { + $children = $item['children'] ?? []; + + if (!is_array($children)) { + return []; + } + + return array_values($children); + } + + private function getParentPath(string $path): ?string + { + $separatorPosition = strrpos($path, '.'); + + if ($separatorPosition === false) { + return null; + } - return "$icon $name"; - }, $this->assetEntries, array_keys($this->assetEntries)); + return substr($path, 0, $separatorPosition); } } diff --git a/src/Editor/Widgets/HierarchyPanel.php b/src/Editor/Widgets/HierarchyPanel.php index 1a43cb7..308daf6 100644 --- a/src/Editor/Widgets/HierarchyPanel.php +++ b/src/Editor/Widgets/HierarchyPanel.php @@ -4,8 +4,11 @@ use Atatusoft\Termutil\Events\Interfaces\ObservableInterface; use Atatusoft\Termutil\Events\Traits\ObservableTrait; +use Atatusoft\Termutil\IO\Enumerations\Color; use Sendama\Console\Editor\Events\EditorEvent; use Sendama\Console\Editor\Events\Enumerations\EventType; +use Sendama\Console\Editor\IO\Enumerations\KeyCode; +use Sendama\Console\Editor\IO\Input; /** * HierarchyPanel class. @@ -16,18 +19,34 @@ class HierarchyPanel extends Widget implements ObservableInterface { use ObservableTrait; + private const string ROOT_PATH = 'scene'; + 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"; + + protected string $sceneName = 'Scene'; + protected bool $isSceneDirty = false; protected array $hierarchy = []; - protected ?int $selectedIndex = null; + protected array $visibleHierarchy = []; + protected array $expandedPaths = []; + protected ?string $selectedPath = null; + protected ?array $pendingInspectionItem = null; public function __construct( array $position = ['x' => 1, 'y' => 1], int $width = 35, int $height = 14, + string $sceneName = 'Scene', + bool $isSceneDirty = false, array $hierarchy = [] ) { $this->initializeObservers(); parent::__construct('Hierarchy', '', $position, $width, $height); + $this->sceneName = $sceneName; + $this->isSceneDirty = $isSceneDirty; $this->setHierarchy($hierarchy); } @@ -38,19 +57,125 @@ public function getHierarchy(): array public function setHierarchy(array $hierarchy): void { - $this->hierarchy = $hierarchy; + $this->hierarchy = array_values($hierarchy); + $this->expandedPaths = [self::ROOT_PATH => true]; + $this->selectedPath = self::ROOT_PATH; $this->refreshContent(); $this->notify(new EditorEvent(EventType::HIERARCHY_CHANGED->value, $this)); } + public function setSceneState(string $sceneName, bool $isDirty = false): void + { + $this->sceneName = $sceneName; + $this->isSceneDirty = $isDirty; + $this->refreshContent(); + } + public function getSelectedHierarchyObject(): ?array { - if ($this->selectedIndex === null) { + $selectedNode = $this->getSelectedVisibleNode(); + + if (($selectedNode['kind'] ?? null) !== 'object') { return null; } - return $this->hierarchy[$this->selectedIndex] ?? null; + return $this->getSelectedVisibleNode()['item'] ?? null; + } + + public function moveSelection(int $offset): void + { + if (!$this->visibleHierarchy) { + return; + } + + $selectedIndex = $this->getSelectedVisibleIndex() ?? 0; + $nextIndex = max(0, min($selectedIndex + $offset, count($this->visibleHierarchy) - 1)); + $this->selectedPath = $this->visibleHierarchy[$nextIndex]['path'] ?? $this->selectedPath; + $this->refreshContent(); + } + + public function expandSelection(): void + { + $selectedNode = $this->getSelectedVisibleNode(); + + if (!$selectedNode || !$selectedNode['hasChildren']) { + return; + } + + if (!$selectedNode['isExpanded']) { + $this->expandedPaths[$selectedNode['path']] = true; + $this->refreshContent(); + return; + } + + $selectedDepth = $selectedNode['depth']; + $selectedPath = $selectedNode['path']; + + foreach ($this->visibleHierarchy as $entry) { + if ( + str_starts_with($entry['path'], $selectedPath . '.') + && $entry['depth'] === $selectedDepth + 1 + ) { + $this->selectedPath = $entry['path']; + $this->refreshContent(); + return; + } + } + } + + public function collapseSelection(): void + { + $selectedNode = $this->getSelectedVisibleNode(); + + if (!$selectedNode) { + return; + } + + if ($selectedNode['hasChildren'] && $selectedNode['isExpanded']) { + unset($this->expandedPaths[$selectedNode['path']]); + $this->refreshContent(); + return; + } + + $parentPath = $this->getParentPath($selectedNode['path']); + + if ($parentPath === null) { + return; + } + + $this->selectedPath = $parentPath; + $this->refreshContent(); + } + + public function activateSelection(): void + { + $selectedNode = $this->getSelectedVisibleNode(); + + if (($selectedNode['kind'] ?? null) !== 'object') { + return; + } + + $selectedItem = $this->getSelectedHierarchyObject(); + + if ($selectedItem === null) { + return; + } + + $this->pendingInspectionItem = [ + 'context' => 'hierarchy', + 'name' => $selectedItem['name'] ?? 'Unnamed Object', + 'type' => $this->resolveInspectableType($selectedItem), + 'value' => $selectedItem, + ]; + } + + public function consumeInspectionRequest(): ?array + { + $pendingInspectionItem = $this->pendingInspectionItem; + $this->pendingInspectionItem = null; + + return $pendingInspectionItem; } public function handleMouseClick(int $x, int $y): void @@ -61,11 +186,11 @@ public function handleMouseClick(int $x, int $y): void $index = $y - $this->getContentAreaTop(); - if (!isset($this->hierarchy[$index])) { + if (!isset($this->visibleHierarchy[$index])) { return; } - $this->selectedIndex = $index; + $this->selectedPath = $this->visibleHierarchy[$index]['path'] ?? $this->selectedPath; $this->refreshContent(); } @@ -74,15 +199,239 @@ public function handleMouseClick(int $x, int $y): void */ public function update(): void { - // TODO: Implement update() method. + if (!$this->hasFocus()) { + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $this->moveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->moveSelection(1); + return; + } + + if (Input::isKeyDown(KeyCode::RIGHT)) { + $this->expandSelection(); + return; + } + + if (Input::isKeyDown(KeyCode::LEFT)) { + $this->collapseSelection(); + return; + } + + if (Input::isKeyDown(KeyCode::ENTER)) { + $this->activateSelection(); + } + } + + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string + { + $selectedVisibleIndex = $this->getSelectedVisibleIndex(); + $selectedLineIndex = $selectedVisibleIndex === null + ? null + : $this->padding->topPadding + $selectedVisibleIndex; + + if ($lineIndex !== $selectedLineIndex) { + return parent::decorateContentLine($line, $contentColor, $lineIndex); + } + + $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; + $selectionSequence = $this->hasFocus() + ? self::SELECTED_ROW_FOCUSED_SEQUENCE + : self::SELECTED_ROW_SEQUENCE; + + return $this->wrapWithColor($leftBorder, $borderColor) + . $this->wrapWithSequence($middle, $selectionSequence) + . $this->wrapWithColor($rightBorder, $borderColor); } private function refreshContent(): void { - $this->content = array_map(function (array $item, int $index) { - $objectName = $item['name'] ?? 'Unnamed Object'; - $icon = $index === $this->selectedIndex ? '>' : '►'; - return "$icon $objectName"; - }, $this->hierarchy, array_keys($this->hierarchy)); + $this->visibleHierarchy = $this->buildVisibleHierarchy(); + $this->syncSelectedPath(); + $this->content = array_map( + fn(array $entry) => $this->formatVisibleHierarchyEntry($entry), + $this->visibleHierarchy + ); + } + + private function buildVisibleHierarchy(): array + { + $rootNode = [ + 'kind' => 'scene', + 'path' => self::ROOT_PATH, + 'item' => [ + 'name' => $this->getDisplaySceneName(), + 'type' => 'Scene', + ], + 'depth' => 0, + 'hasChildren' => $this->hierarchy !== [], + 'isExpanded' => isset($this->expandedPaths[self::ROOT_PATH]), + ]; + + $visibleHierarchy = [$rootNode]; + + if ($rootNode['isExpanded']) { + $visibleHierarchy = [ + ...$visibleHierarchy, + ...$this->buildChildHierarchy($this->hierarchy, 1, self::ROOT_PATH), + ]; + } + + return $visibleHierarchy; + } + + private function buildChildHierarchy(array $items, int $depth, string $parentPath): array + { + $visibleHierarchy = []; + + foreach (array_values($items) as $index => $item) { + if (!is_array($item)) { + continue; + } + + $path = $parentPath . '.' . $index; + $children = $this->getChildItems($item); + $hasChildren = $children !== []; + $isExpanded = $hasChildren && isset($this->expandedPaths[$path]); + + $visibleHierarchy[] = [ + 'kind' => 'object', + 'path' => $path, + 'item' => $item, + 'depth' => $depth, + 'hasChildren' => $hasChildren, + 'isExpanded' => $isExpanded, + ]; + + if ($isExpanded) { + $visibleHierarchy = [ + ...$visibleHierarchy, + ...$this->buildChildHierarchy($children, $depth + 1, $path), + ]; + } + } + + return $visibleHierarchy; + } + + private function syncSelectedPath(): void + { + if ($this->selectedPath !== null && $this->findVisibleIndexByPath($this->selectedPath) !== null) { + return; + } + + $candidatePath = $this->selectedPath; + + while ($candidatePath !== null) { + $candidatePath = $this->getParentPath($candidatePath); + + if ($candidatePath !== null && $this->findVisibleIndexByPath($candidatePath) !== null) { + $this->selectedPath = $candidatePath; + return; + } + } + + $this->selectedPath = $this->visibleHierarchy[0]['path'] ?? null; + } + + private function getSelectedVisibleNode(): ?array + { + $selectedVisibleIndex = $this->getSelectedVisibleIndex(); + + if ($selectedVisibleIndex === null) { + return null; + } + + return $this->visibleHierarchy[$selectedVisibleIndex] ?? null; + } + + private function getSelectedVisibleIndex(): ?int + { + return $this->findVisibleIndexByPath($this->selectedPath); + } + + private function findVisibleIndexByPath(?string $path): ?int + { + if ($path === null) { + return null; + } + + foreach ($this->visibleHierarchy as $index => $entry) { + if (($entry['path'] ?? null) === $path) { + return $index; + } + } + + return null; + } + + private function formatVisibleHierarchyEntry(array $entry): string + { + $icon = match (true) { + ($entry['hasChildren'] ?? false) && ($entry['isExpanded'] ?? false) => self::EXPANDED_ICON, + ($entry['hasChildren'] ?? false) => self::COLLAPSED_ICON, + default => self::LEAF_ICON, + }; + $name = $entry['item']['name'] ?? 'Unnamed Object'; + $indentation = str_repeat(' ', (int)($entry['depth'] ?? 0)); + + return $indentation . $icon . ' ' . $name; + } + + private function getDisplaySceneName(): string + { + return $this->isSceneDirty ? $this->sceneName . '*' : $this->sceneName; + } + + private function resolveInspectableType(array $selectedItem): string + { + $type = $selectedItem['type'] ?? null; + + if (!is_string($type) || $type === '') { + return 'Unknown'; + } + + $normalizedType = ltrim($type, '\\'); + $normalizedType = preg_replace('/::class$/', '', $normalizedType) ?? $normalizedType; + $typeSegments = explode('\\', $normalizedType); + + return end($typeSegments) ?: $normalizedType; + } + + private function getChildItems(array $item): array + { + $children = $item['children'] ?? []; + + if (!is_array($children)) { + return []; + } + + return array_values($children); + } + + private function getParentPath(string $path): ?string + { + $separatorPosition = strrpos($path, '.'); + + if ($separatorPosition === false) { + return null; + } + + return substr($path, 0, $separatorPosition); } } diff --git a/src/Editor/Widgets/InspectorPanel.php b/src/Editor/Widgets/InspectorPanel.php index 81923e4..5aef59f 100644 --- a/src/Editor/Widgets/InspectorPanel.php +++ b/src/Editor/Widgets/InspectorPanel.php @@ -6,6 +6,8 @@ class InspectorPanel extends Widget { + protected ?array $inspectionTarget = null; + public function __construct( array $position = ['x' => 135, 'y' => 1], int $width = 35, @@ -15,6 +17,24 @@ public function __construct( parent::__construct('Inspector', '', $position, $width, $height); } + public function inspectTarget(?array $target): void + { + $this->inspectionTarget = $target; + $selectedItemType = $target['type'] ?? null; + $selectedItemName = $target['name'] ?? null; + $content = []; + + if ($selectedItemType !== null) { + $content[] = "Type: {$selectedItemType}"; + } + + if ($selectedItemName !== null) { + $content[] = "Name: {$selectedItemName}"; + } + + $this->content = $content; + } + /** * @inheritDoc */ @@ -22,4 +42,4 @@ public function update(): void { // TODO: Implement update() method. } -} \ No newline at end of file +} diff --git a/src/Editor/Widgets/Widget.php b/src/Editor/Widgets/Widget.php index b0c3773..d183b04 100644 --- a/src/Editor/Widgets/Widget.php +++ b/src/Editor/Widgets/Widget.php @@ -152,14 +152,14 @@ public function renderAt(?int $x = null, ?int $y = null): void foreach ($linesOfContent as $index => $line) { $this->cursor->moveTo($leftMargin, $topMargin + $index + 1); - echo $this->decorateContentLine($line, $contentColor); + echo $this->decorateContentLine($line, $contentColor, $index); } $this->cursor->moveTo($leftMargin, $topMargin + count($linesOfContent) + 1); echo $this->decorateBorderLine($bottomBorder, $contentColor); } - private function decorateBorderLine(string $line, ?Color $contentColor): string + protected function decorateBorderLine(string $line, ?Color $contentColor): string { $visibleLine = mb_substr($line, 0, $this->width); $borderColor = $this->hasFocus ? $this->focusBorderColor : $contentColor; @@ -167,7 +167,7 @@ private function decorateBorderLine(string $line, ?Color $contentColor): string return $this->wrapWithColor($visibleLine, $borderColor); } - private function decorateContentLine(string $line, ?Color $contentColor): string + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string { $visibleLine = mb_substr($line, 0, $this->width); @@ -190,13 +190,18 @@ private function decorateContentLine(string $line, ?Color $contentColor): string . $this->wrapWithColor($rightBorder, $this->focusBorderColor); } - private function wrapWithColor(string $content, ?Color $color): string + protected function wrapWithColor(string $content, ?Color $color): string { - if ($content === '' || $color === null) { + return $this->wrapWithSequence($content, $color?->value); + } + + protected function wrapWithSequence(string $content, ?string $sequence): string + { + if ($content === '' || $sequence === null) { return $content; } - return $color->value . $content . Color::RESET->value; + return $sequence . $content . Color::RESET->value; } /** diff --git a/tests/Unit/SceneLoaderTest.php b/tests/Unit/SceneLoaderTest.php index a46b6a0..63516aa 100644 --- a/tests/Unit/SceneLoaderTest.php +++ b/tests/Unit/SceneLoaderTest.php @@ -48,3 +48,45 @@ expect($scene->name)->toBe('alpha'); expect($scene->hierarchy[0]['name'])->toBe('Alpha'); }); + +test('scene loader extracts hierarchy types from source when evaluation fails', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-loader-' . uniqid(); + mkdir($workspace . '/Assets/Scenes', 0777, true); + + file_put_contents( + $workspace . '/Assets/Scenes/level01.scene.php', + <<<'PHP' + [ + [ + 'type' => GameObject::class, + 'name' => 'Player', + 'position' => ['x' => DEFAULT_SCREEN_WIDTH / 2, 'y' => 0], + ], + [ + 'type' => Label::class, + 'name' => 'Score', + ], + ], +]; +PHP + ); + + $loader = new SceneLoader($workspace); + $scene = $loader->load(new EditorSceneSettings(active: 0, loaded: ['level01'])); + + expect($scene)->not->toBeNull(); + expect($scene->hierarchy[0])->toBe([ + 'name' => 'Player', + 'type' => 'GameObject::class', + ]); + expect($scene->hierarchy[1])->toBe([ + 'name' => 'Score', + 'type' => 'Label::class', + ]); +}); From 87faf577b514339583c4440878c1fce7dbe81e69 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 11 Mar 2026 19:57:44 +0200 Subject: [PATCH 4/6] test(editor): add unit tests for AssetsPanel and HierarchyPanel functionality --- tests/Unit/AssetsPanelTest.php | 93 ++++++++++++++++++++ tests/Unit/HierarchyPanelTest.php | 137 ++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 tests/Unit/AssetsPanelTest.php create mode 100644 tests/Unit/HierarchyPanelTest.php diff --git a/tests/Unit/AssetsPanelTest.php b/tests/Unit/AssetsPanelTest.php new file mode 100644 index 0000000..37540c8 --- /dev/null +++ b/tests/Unit/AssetsPanelTest.php @@ -0,0 +1,93 @@ +getSelectedAssetEntry()['name'])->toBe('Scripts'); + expect($panel->content[0])->toBe('► Scripts'); + expect($panel->content[1])->toBe('► Textures'); + expect($panel->content[2])->toBe('• readme.txt'); + + $panel->expandSelection(); + + expect($panel->content[0])->toBe('▼ Scripts'); + expect($panel->content[1])->toBe(' ► Player'); + + $panel->expandSelection(); + + expect($panel->getSelectedAssetEntry()['name'])->toBe('Player'); + + $panel->expandSelection(); + $panel->moveSelection(1); + + expect($panel->getSelectedAssetEntry()['name'])->toBe('controller.php'); +}); + +test('assets panel queues the selected asset for inspection', function () { + $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); + mkdir($workspace . '/Assets', 0777, true); + file_put_contents($workspace . '/Assets/readme.txt', 'docs'); + + $panel = new AssetsPanel( + width: 40, + height: 12, + assetsDirectoryPath: $workspace . '/Assets', + ); + + $panel->moveSelection(0); + $panel->activateSelection(); + + expect($panel->consumeInspectionRequest())->toBe([ + 'context' => 'asset', + 'name' => 'readme.txt', + 'type' => 'File', + 'value' => [ + 'name' => 'readme.txt', + 'path' => $workspace . '/Assets/readme.txt', + 'relativePath' => 'readme.txt', + 'isDirectory' => false, + 'children' => [], + ], + ]); + expect($panel->consumeInspectionRequest())->toBeNull(); +}); + +test('assets panel reports folders as folder type in inspector payload', function () { + $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); + mkdir($workspace . '/Assets/Scripts', 0777, true); + + $panel = new AssetsPanel( + width: 40, + height: 12, + assetsDirectoryPath: $workspace . '/Assets', + ); + + $panel->activateSelection(); + + expect($panel->consumeInspectionRequest())->toBe([ + 'context' => 'asset', + 'name' => 'Scripts', + 'type' => 'Folder', + 'value' => [ + 'name' => 'Scripts', + 'path' => $workspace . '/Assets/Scripts', + 'relativePath' => 'Scripts', + 'isDirectory' => true, + 'children' => [], + ], + ]); +}); diff --git a/tests/Unit/HierarchyPanelTest.php b/tests/Unit/HierarchyPanelTest.php new file mode 100644 index 0000000..51531ef --- /dev/null +++ b/tests/Unit/HierarchyPanelTest.php @@ -0,0 +1,137 @@ + 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'children' => [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Gun'], + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Shield'], + ], + ], + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Enemy'], + ], + ); + + expect($panel->getSelectedHierarchyObject())->toBeNull(); + expect($panel->content[0])->toBe('▼ level01'); + expect($panel->content[1])->toBe(' ► Player'); + + $panel->expandSelection(); + + expect($panel->getSelectedHierarchyObject()['name'])->toBe('Player'); + + $panel->expandSelection(); + + expect($panel->content[1])->toBe(' ▼ Player'); + expect($panel->content[2])->toBe(' • Gun'); + expect($panel->content[3])->toBe(' • Shield'); + + $panel->moveSelection(1); + + expect($panel->getSelectedHierarchyObject()['name'])->toBe('Gun'); + + $panel->moveSelection(1); + + expect($panel->getSelectedHierarchyObject()['name'])->toBe('Shield'); +}); + +test('hierarchy panel collapses back to the parent node', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'children' => [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Gun'], + ], + ], + ], + ); + + $panel->expandSelection(); + $panel->expandSelection(); + $panel->moveSelection(1); + + expect($panel->getSelectedHierarchyObject()['name'])->toBe('Gun'); + + $panel->collapseSelection(); + + expect($panel->getSelectedHierarchyObject()['name'])->toBe('Player'); + expect($panel->content[1])->toBe(' ▼ Player'); +}); + +test('hierarchy panel queues the selected object for inspection', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player'], + ], + ); + + $panel->expandSelection(); + $panel->activateSelection(); + + expect($panel->consumeInspectionRequest())->toBe([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'value' => ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player'], + ]); + expect($panel->consumeInspectionRequest())->toBeNull(); +}); + +test('hierarchy panel infers inspector type from class metadata', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + ['type' => '\\Sendama\\Engine\\UI\\Label\\Label', 'name' => 'Score'], + ], + ); + + $panel->expandSelection(); + $panel->activateSelection(); + + expect($panel->consumeInspectionRequest()['type'])->toBe('Label'); +}); + +test('hierarchy panel marks dirty scenes in the root label', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + isSceneDirty: true, + hierarchy: [], + ); + + expect($panel->content[0])->toBe('• level01*'); +}); + +test('hierarchy panel does not inspect the scene root', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player'], + ], + ); + + $panel->activateSelection(); + + expect($panel->consumeInspectionRequest())->toBeNull(); +}); From 4061cb3b5be18a8dbc802e0bf8b933ed7cef184d Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 11 Mar 2026 23:52:54 +0200 Subject: [PATCH 5/6] feat(editor): implement input controls and enhance console panel functionality --- src/Editor/Editor.php | 81 +- src/Editor/IO/Enumerations/KeyCode.php | 4 + src/Editor/IO/InputManager.php | 4 + src/Editor/Widgets/ConsolePanel.php | 148 +++- .../Widgets/Controls/CheckboxInputControl.php | 37 + .../Widgets/Controls/CompoundInputControl.php | 264 +++++++ src/Editor/Widgets/Controls/InputControl.php | 158 ++++ .../Widgets/Controls/InputControlFactory.php | 53 ++ .../Widgets/Controls/NumberInputControl.php | 108 +++ .../Widgets/Controls/PreviewWindowControl.php | 35 + .../Widgets/Controls/SelectInputControl.php | 53 ++ .../Widgets/Controls/TextInputControl.php | 118 +++ .../Widgets/Controls/VectorInputControl.php | 47 ++ src/Editor/Widgets/InspectorPanel.php | 716 +++++++++++++++++- src/Editor/Widgets/MainPanel.php | 73 +- src/Editor/Widgets/Widget.php | 79 ++ tests/Unit/ConsolePanelTest.php | 108 +++ tests/Unit/InputManagerTest.php | 44 ++ tests/Unit/InspectorPanelTest.php | 166 ++++ tests/Unit/MainPanelTest.php | 3 +- tests/Unit/TextInputControlTest.php | 15 + tests/Unit/VectorInputControlTest.php | 22 + tests/Unit/WidgetTest.php | 48 ++ 23 files changed, 2350 insertions(+), 34 deletions(-) create mode 100644 src/Editor/Widgets/Controls/CheckboxInputControl.php create mode 100644 src/Editor/Widgets/Controls/CompoundInputControl.php create mode 100644 src/Editor/Widgets/Controls/InputControl.php create mode 100644 src/Editor/Widgets/Controls/InputControlFactory.php create mode 100644 src/Editor/Widgets/Controls/NumberInputControl.php create mode 100644 src/Editor/Widgets/Controls/PreviewWindowControl.php create mode 100644 src/Editor/Widgets/Controls/SelectInputControl.php create mode 100644 src/Editor/Widgets/Controls/TextInputControl.php create mode 100644 src/Editor/Widgets/Controls/VectorInputControl.php create mode 100644 tests/Unit/ConsolePanelTest.php create mode 100644 tests/Unit/InputManagerTest.php create mode 100644 tests/Unit/InspectorPanelTest.php create mode 100644 tests/Unit/TextInputControlTest.php create mode 100644 tests/Unit/VectorInputControlTest.php create mode 100644 tests/Unit/WidgetTest.php diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php index 3168e6c..d8b6f63 100644 --- a/src/Editor/Editor.php +++ b/src/Editor/Editor.php @@ -29,6 +29,7 @@ use Sendama\Console\Editor\Widgets\Widget; use Sendama\Console\Exceptions\IOException; use Sendama\Console\Exceptions\SendamaConsoleException; +use Sendama\Console\Util\Path; use Symfony\Component\Console\Output\ConsoleOutput; use Throwable; @@ -358,6 +359,8 @@ private function update(): void return; } + $this->consolePanel->setPlayModeActive($this->editorState instanceof PlayState); + foreach ($this->panels as $panel) { $panel->update(); } @@ -526,7 +529,9 @@ private function initializeWidgets(): void assetsDirectoryPath: $this->assetsDirectoryPath, ); $this->mainPanel = new MainPanel(); - $this->consolePanel = new ConsolePanel(); + $this->consolePanel = new ConsolePanel( + logFilePath: Path::join($this->workingDirectory, 'logs', 'debug.log'), + ); $this->inspectorPanel = new InspectorPanel(); $this->panels->add($this->hierarchyPanel); @@ -536,6 +541,7 @@ private function initializeWidgets(): void $this->panels->add($this->inspectorPanel); $this->layoutPanels(); + $this->configurePanelGraph(); $this->setFocusedPanel($this->mainPanel); } @@ -593,13 +599,33 @@ private function handlePanelKeyboardWorkflow(): void return; } - if (Input::isKeyDown(IO\Enumerations\KeyCode::SHIFT_TAB)) { - $this->focusPreviousPanel(); + if (Input::isKeyDown(IO\Enumerations\KeyCode::SHIFT_UP)) { + $this->focusSiblingPanel('top'); + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::SHIFT_RIGHT)) { + $this->focusSiblingPanel('right'); + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::SHIFT_DOWN)) { + $this->focusSiblingPanel('bottom'); + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::SHIFT_LEFT)) { + $this->focusSiblingPanel('left'); return; } if (Input::isKeyDown(IO\Enumerations\KeyCode::TAB)) { - $this->focusNextPanel(); + $this->focusedPanel?->cycleFocusForward(); + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::SHIFT_TAB)) { + $this->focusedPanel?->cycleFocusBackward(); } } @@ -663,6 +689,53 @@ private function layoutPanels(): void $this->panelListModal->syncLayout($this->terminalWidth, $this->terminalHeight); } + private function configurePanelGraph(): void + { + $this->hierarchyPanel->setSiblings( + top: null, + right: $this->mainPanel, + bottom: $this->assetsPanel, + left: null, + ); + + $this->assetsPanel->setSiblings( + top: $this->hierarchyPanel, + right: $this->consolePanel, + bottom: null, + left: null, + ); + + $this->consolePanel->setSiblings( + top: $this->mainPanel, + right: $this->inspectorPanel, + bottom: null, + left: $this->assetsPanel, + ); + + $this->mainPanel->setSiblings( + top: null, + right: $this->inspectorPanel, + bottom: $this->consolePanel, + left: $this->hierarchyPanel, + ); + + $this->inspectorPanel->setSiblings( + top: null, + right: null, + bottom: null, + left: $this->mainPanel, + ); + } + + private function focusSiblingPanel(string $direction): void + { + $targetPanel = $this->focusedPanel?->getSibling($direction); + + if ($targetPanel instanceof Widget) { + $this->setFocusedPanel($targetPanel); + } + } + private function focusNextPanel(): void { $panels = $this->panels->toArray(); diff --git a/src/Editor/IO/Enumerations/KeyCode.php b/src/Editor/IO/Enumerations/KeyCode.php index 332cd47..4d1bed6 100644 --- a/src/Editor/IO/Enumerations/KeyCode.php +++ b/src/Editor/IO/Enumerations/KeyCode.php @@ -13,6 +13,10 @@ enum KeyCode: string case SPACE = 'space'; case TAB = 'tab'; case SHIFT_TAB = 'shift_tab'; + case SHIFT_UP = 'shift_up'; + case SHIFT_RIGHT = 'shift_right'; + case SHIFT_DOWN = 'shift_down'; + case SHIFT_LEFT = 'shift_left'; case BACKSPACE = 'backspace'; case ESCAPE = 'escape'; case DELETE = 'delete'; diff --git a/src/Editor/IO/InputManager.php b/src/Editor/IO/InputManager.php index 2162d43..01fa4c1 100644 --- a/src/Editor/IO/InputManager.php +++ b/src/Editor/IO/InputManager.php @@ -132,8 +132,12 @@ private static function getKey(?string $keyPress): string return match ($keyPress) { "\033[A" => KeyCode::UP->value, "\033[B" => KeyCode::DOWN->value, + "\033[1;2A", "\033[a" => KeyCode::SHIFT_UP->value, + "\033[1;2B", "\033[b" => KeyCode::SHIFT_DOWN->value, "\033[C" => KeyCode::RIGHT->value, "\033[D" => KeyCode::LEFT->value, + "\033[1;2C", "\033[c" => KeyCode::SHIFT_RIGHT->value, + "\033[1;2D", "\033[d" => KeyCode::SHIFT_LEFT->value, "\033[Z", "\033[1;2Z" => KeyCode::SHIFT_TAB->value, "\n" => KeyCode::ENTER->value, " " => KeyCode::SPACE->value, diff --git a/src/Editor/Widgets/ConsolePanel.php b/src/Editor/Widgets/ConsolePanel.php index 365d944..4569bfa 100644 --- a/src/Editor/Widgets/ConsolePanel.php +++ b/src/Editor/Widgets/ConsolePanel.php @@ -2,34 +2,174 @@ namespace Sendama\Console\Editor\Widgets; +use Atatusoft\Termutil\IO\Enumerations\Color; +use Sendama\Console\Editor\IO\Enumerations\KeyCode; +use Sendama\Console\Editor\IO\Input; + class ConsolePanel extends Widget { + private const int INITIAL_TAIL_LINE_COUNT = 3; + protected array $messages = []; + protected int $scrollOffset = 0; + protected bool $isPlayModeActive = false; public function __construct( array $position = ['x' => 37, 'y' => 22], int $width = 96, - int $height = 8 + int $height = 8, + protected ?string $logFilePath = null ) { parent::__construct('Console', '', $position, $width, $height); + $this->loadInitialLogTail(); $this->update(); } public function append(string $message): void { $this->messages[] = $message; - $this->update(); + $this->scrollToRecentLines(); + $this->refreshVisibleContent(); } public function clear(): void { $this->messages = []; - $this->update(); + $this->scrollOffset = 0; + $this->refreshVisibleContent(); + } + + public function setPlayModeActive(bool $isPlayModeActive): void + { + $this->isPlayModeActive = $isPlayModeActive; + } + + public function scrollUp(): void + { + if ($this->messages === []) { + return; + } + + $this->scrollOffset = max(0, $this->scrollOffset - 1); + $this->refreshVisibleContent(); + } + + public function scrollDown(): void + { + if ($this->messages === []) { + return; + } + + $this->scrollOffset = min(count($this->messages) - 1, $this->scrollOffset + 1); + $this->refreshVisibleContent(); } public function update(): void { - $this->content = array_slice($this->messages, -$this->innerHeight); + if ($this->hasFocus() && !$this->isPlayModeActive) { + if (Input::isKeyDown(KeyCode::UP)) { + $this->scrollUp(); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->scrollDown(); + return; + } + } + + $this->refreshVisibleContent(); + } + + protected function decorateContentLine(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; + + return $this->wrapWithColor($leftBorder, $borderColor) + . $this->colorizeLogTag($middle) + . $this->wrapWithColor($rightBorder, $borderColor); + } + + private function loadInitialLogTail(): void + { + if ($this->logFilePath === null || !is_file($this->logFilePath)) { + return; + } + + $lines = file($this->logFilePath, FILE_IGNORE_NEW_LINES); + + if ($lines === false) { + return; + } + + $this->messages = $lines; + $this->scrollToRecentLines(); + } + + private function colorizeLogTag(string $content): string + { + if (preg_match('/\[(ERROR|INFO|WARN|WARNING|DEBUG)\]/', $content, $matches, PREG_OFFSET_CAPTURE) !== 1) { + return $content; + } + + $tag = $matches[0][0]; + $tagOffset = $matches[0][1]; + $level = $matches[1][0]; + $beforeTag = substr($content, 0, $tagOffset); + $afterTag = substr($content, $tagOffset + strlen($tag)); + + return $beforeTag + . $this->wrapWithColor($tag, $this->resolveLogLevelColor($level)) + . $afterTag; + } + + private function resolveLogLevelColor(string $level): ?Color + { + return match ($level) { + 'ERROR' => Color::LIGHT_RED, + 'INFO' => Color::LIGHT_BLUE, + 'WARN', 'WARNING' => Color::YELLOW, + 'DEBUG' => Color::LIGHT_GRAY, + default => null, + }; + } + + private function scrollToRecentLines(): void + { + $messageCount = count($this->messages); + + if ($messageCount === 0) { + $this->scrollOffset = 0; + return; + } + + $this->scrollOffset = max(0, $messageCount - self::INITIAL_TAIL_LINE_COUNT); + } + + private function clampScrollOffset(): void + { + if ($this->messages === []) { + $this->scrollOffset = 0; + return; + } + + $this->scrollOffset = max(0, min($this->scrollOffset, count($this->messages) - 1)); + } + + private function refreshVisibleContent(): void + { + $this->clampScrollOffset(); + $this->content = array_slice($this->messages, $this->scrollOffset, $this->innerHeight); } } diff --git a/src/Editor/Widgets/Controls/CheckboxInputControl.php b/src/Editor/Widgets/Controls/CheckboxInputControl.php new file mode 100644 index 0000000..436b198 --- /dev/null +++ b/src/Editor/Widgets/Controls/CheckboxInputControl.php @@ -0,0 +1,37 @@ +isEditing) { + return false; + } + + $this->value = true; + + return true; + } + + public function decrement(): bool + { + if (!$this->isEditing) { + return false; + } + + $this->value = false; + + return true; + } + + public function renderLines(): array + { + $isChecked = (bool) $this->value; + + return [ + $this->indentation() . $this->label . ': [' . ($isChecked ? 'x' : ' ') . ']', + ]; + } +} diff --git a/src/Editor/Widgets/Controls/CompoundInputControl.php b/src/Editor/Widgets/Controls/CompoundInputControl.php new file mode 100644 index 0000000..73b7fa3 --- /dev/null +++ b/src/Editor/Widgets/Controls/CompoundInputControl.php @@ -0,0 +1,264 @@ + + */ + protected array $controls = []; + protected ?int $selectedControlIndex = null; + protected bool $isSelectingProperty = false; + + /** + * @param array $controls + */ + public function __construct( + string $label, + mixed $value, + array $controls, + int $indentLevel = 1, + bool $isReadOnly = false, + ) + { + parent::__construct($label, $value, $indentLevel, $isReadOnly); + $this->controls = $controls; + } + + /** + * @return array + */ + public function getControls(): array + { + return $this->controls; + } + + public function getValue(): mixed + { + $this->synchronizeValueFromChildren(); + + return $this->value; + } + + public function beginPropertySelection(): bool + { + if ($this->controls === []) { + return false; + } + + $this->isSelectingProperty = true; + $this->selectedControlIndex ??= 0; + $this->applyPropertySelection(); + + return true; + } + + public function endPropertySelection(): void + { + $this->isSelectingProperty = false; + + foreach ($this->controls as $control) { + $control->blur(); + + if ($control->isEditing()) { + $control->cancelEdit(); + } + } + } + + public function isSelectingProperty(): bool + { + return $this->isSelectingProperty; + } + + public function movePropertySelection(int $offset): bool + { + if (!$this->isSelectingProperty || $this->controls === []) { + return false; + } + + $this->selectedControlIndex ??= 0; + $this->selectedControlIndex = ($this->selectedControlIndex + $offset + count($this->controls)) + % count($this->controls); + + $this->applyPropertySelection(); + + return true; + } + + public function enterSelectedPropertyEdit(): bool + { + $selectedControl = $this->getSelectedPropertyControl(); + + if (!$selectedControl instanceof InputControl) { + return false; + } + + return $selectedControl->enterEditMode(); + } + + public function commitActiveEdit(): bool + { + $selectedControl = $this->getSelectedPropertyControl(); + + if (!$selectedControl instanceof InputControl) { + return false; + } + + $didCommit = $selectedControl->commitEdit(); + + if ($didCommit) { + $this->synchronizeValueFromChildren(); + } + + return $didCommit; + } + + public function cancelActiveEdit(): void + { + $selectedControl = $this->getSelectedPropertyControl(); + $selectedControl?->cancelEdit(); + } + + public function handleInput(string $input): bool + { + $selectedControl = $this->getSelectedPropertyControl(); + + if (!$selectedControl instanceof InputControl) { + return false; + } + + return $selectedControl->handleInput($input); + } + + public function deleteBackward(): bool + { + $selectedControl = $this->getSelectedPropertyControl(); + + if (!$selectedControl instanceof InputControl) { + return false; + } + + return $selectedControl->deleteBackward(); + } + + public function moveCursorLeft(): bool + { + $selectedControl = $this->getSelectedPropertyControl(); + + if (!$selectedControl instanceof InputControl) { + return false; + } + + return $selectedControl->moveCursorLeft(); + } + + public function moveCursorRight(): bool + { + $selectedControl = $this->getSelectedPropertyControl(); + + if (!$selectedControl instanceof InputControl) { + return false; + } + + return $selectedControl->moveCursorRight(); + } + + public function increment(): bool + { + $selectedControl = $this->getSelectedPropertyControl(); + + if (!$selectedControl instanceof InputControl) { + return false; + } + + return $selectedControl->increment(); + } + + public function decrement(): bool + { + $selectedControl = $this->getSelectedPropertyControl(); + + if (!$selectedControl instanceof InputControl) { + return false; + } + + return $selectedControl->decrement(); + } + + public function renderLines(): array + { + return array_column($this->renderLineDefinitions(), 'text'); + } + + public function renderLineDefinitions(): array + { + $lineDefinitions = [[ + 'text' => $this->indentation() . $this->label . ':', + 'state' => $this->hasFocus && !$this->isSelectingProperty ? 'selected' : 'normal', + ]]; + + foreach ($this->controls as $index => $control) { + foreach ($control->renderLineDefinitions() as $lineDefinition) { + $lineDefinitions[] = [ + 'text' => $lineDefinition['text'], + 'state' => $this->resolveChildLineState($control, $index, $lineDefinition['state'] ?? 'normal'), + ]; + } + } + + return $lineDefinitions; + } + + private function resolveChildLineState(InputControl $control, int $index, string $defaultState): string + { + if (!$this->isSelectingProperty || $this->selectedControlIndex !== $index) { + return 'normal'; + } + + if ($control->isEditing()) { + return 'editing'; + } + + return $defaultState === 'editing' ? 'editing' : 'selected'; + } + + private function getSelectedPropertyControl(): ?InputControl + { + if ($this->selectedControlIndex === null) { + return null; + } + + return $this->controls[$this->selectedControlIndex] ?? null; + } + + private function applyPropertySelection(): void + { + foreach ($this->controls as $index => $control) { + if ($index === $this->selectedControlIndex) { + $control->focus(); + continue; + } + + $control->blur(); + + if ($control->isEditing()) { + $control->cancelEdit(); + } + } + } + + private function synchronizeValueFromChildren(): void + { + $value = []; + + foreach ($this->controls as $control) { + $value[mb_strtolower($control->getLabel())] = $control->getValue(); + } + + if ($value !== []) { + $this->value = $value; + } + } +} diff --git a/src/Editor/Widgets/Controls/InputControl.php b/src/Editor/Widgets/Controls/InputControl.php new file mode 100644 index 0000000..bb49058 --- /dev/null +++ b/src/Editor/Widgets/Controls/InputControl.php @@ -0,0 +1,158 @@ +label; + } + + public function getValue(): mixed + { + return $this->value; + } + + public function setValue(mixed $value): void + { + $this->value = $value; + } + + public function focus(): void + { + $this->hasFocus = true; + } + + public function blur(): void + { + $this->hasFocus = false; + } + + public function hasFocus(): bool + { + return $this->hasFocus; + } + + public function isEditing(): bool + { + return $this->isEditing; + } + + public function isEditable(): bool + { + return !$this->isReadOnly; + } + + public function enterEditMode(): bool + { + if (!$this->isEditable()) { + return false; + } + + $this->isEditing = true; + + return true; + } + + public function commitEdit(): bool + { + $this->isEditing = false; + + return true; + } + + public function cancelEdit(): void + { + $this->isEditing = false; + } + + public function handleInput(string $input): bool + { + return false; + } + + public function deleteBackward(): bool + { + return false; + } + + public function moveCursorLeft(): bool + { + return false; + } + + public function moveCursorRight(): bool + { + return false; + } + + public function increment(): bool + { + return false; + } + + public function decrement(): bool + { + return false; + } + + public function update(): void + { + } + + abstract public function renderLines(): array; + + public function renderLineDefinitions(): array + { + $state = $this->resolveLineState(); + + return array_map( + fn(string $line) => ['text' => $line, 'state' => $state], + $this->renderLines(), + ); + } + + protected function indentation(int $offset = 0): string + { + return str_repeat(' ', max(0, $this->indentLevel + $offset)); + } + + protected function formatScalarValue(mixed $value): string + { + return match (true) { + is_bool($value) => $value ? 'true' : 'false', + $value === null => 'None', + is_scalar($value) => (string) $value, + default => json_encode($value, JSON_UNESCAPED_SLASHES) ?: 'None', + }; + } + + protected function resolveLineState(): string + { + return match (true) { + $this->isEditing => 'editing', + $this->hasFocus => 'selected', + default => 'normal', + }; + } + + protected function isPrintableInput(string $input): bool + { + return $input !== '' + && mb_strlen($input) === 1 + && !(function_exists('ctype_cntrl') && ctype_cntrl($input)); + } +} diff --git a/src/Editor/Widgets/Controls/InputControlFactory.php b/src/Editor/Widgets/Controls/InputControlFactory.php new file mode 100644 index 0000000..7adbfbf --- /dev/null +++ b/src/Editor/Widgets/Controls/InputControlFactory.php @@ -0,0 +1,53 @@ + new CheckboxInputControl($label, $value, $indentLevel), + is_int($value), is_float($value) => new NumberInputControl($label, $value, $indentLevel), + is_array($value) && $this->isVector($value) => new VectorInputControl($label, $value, $indentLevel), + is_array($value) && array_is_list($value) && $this->containsOnlyScalarValues($value) => new SelectInputControl($label, $value, null, $indentLevel), + default => new TextInputControl($label, $this->normalizeTextValue($value), $indentLevel), + }; + } + + private function isVector(array $value): bool + { + if ($value === []) { + return false; + } + + foreach (array_keys($value) as $key) { + if (!is_string($key) || !in_array($key, ['x', 'y', 'z', 'w'], true)) { + return false; + } + } + + return $this->containsOnlyScalarValues($value); + } + + private function containsOnlyScalarValues(array $value): bool + { + foreach ($value as $item) { + if (!is_scalar($item) && $item !== null) { + return false; + } + } + + return true; + } + + private function normalizeTextValue(mixed $value): string + { + return match (true) { + is_array($value) => json_encode($value, JSON_UNESCAPED_SLASHES) ?: 'None', + is_bool($value) => $value ? 'true' : 'false', + $value === null => 'None', + default => (string) $value, + }; + } +} diff --git a/src/Editor/Widgets/Controls/NumberInputControl.php b/src/Editor/Widgets/Controls/NumberInputControl.php new file mode 100644 index 0000000..ad46d9b --- /dev/null +++ b/src/Editor/Widgets/Controls/NumberInputControl.php @@ -0,0 +1,108 @@ +prefersFloat = is_float($value) || (is_string($value) && str_contains($value, '.')); + } + + public function handleInput(string $input): bool + { + if (!$this->isEditing || !preg_match('/^[0-9.\-]$/', $input)) { + return false; + } + + if ($input === '.' && str_contains($this->editingValue, '.')) { + return false; + } + + if ($input === '-' && $this->cursorPosition !== 0) { + return false; + } + + if ($input === '-' && str_contains($this->editingValue, '-')) { + return false; + } + + return parent::handleInput($input); + } + + public function increment(): bool + { + if (!$this->isEditing) { + return false; + } + + $this->editingValue = $this->formatCommittedNumber($this->parseEditingValue() + 1); + $this->cursorPosition = mb_strlen($this->editingValue); + + return true; + } + + public function decrement(): bool + { + if (!$this->isEditing) { + return false; + } + + $this->editingValue = $this->formatCommittedNumber($this->parseEditingValue() - 1); + $this->cursorPosition = mb_strlen($this->editingValue); + + return true; + } + + protected function getRenderedValue(): string + { + if ($this->isEditing) { + return parent::getRenderedValue(); + } + + return $this->formatCommittedNumber($this->value); + } + + private function parseEditingValue(): float + { + if ($this->editingValue === '' || $this->editingValue === '-' || $this->editingValue === '.') { + return 0.0; + } + + if (!is_numeric($this->editingValue)) { + return 0.0; + } + + return (float) $this->editingValue; + } + + private function formatCommittedNumber(mixed $value): string + { + $numericValue = is_numeric($value) ? $value + 0 : 0; + + if ($this->prefersFloat) { + $formattedValue = rtrim(rtrim(number_format((float) $numericValue, 6, '.', ''), '0'), '.'); + + return $formattedValue === '' ? '0' : $formattedValue; + } + + return (string) (int) round((float) $numericValue); + } + + protected function transformCommittedValue(string $editingValue): mixed + { + $numericValue = $this->parseEditingValue(); + + return $this->prefersFloat + ? (float) $numericValue + : (int) round($numericValue); + } +} diff --git a/src/Editor/Widgets/Controls/PreviewWindowControl.php b/src/Editor/Widgets/Controls/PreviewWindowControl.php new file mode 100644 index 0000000..298eb1a --- /dev/null +++ b/src/Editor/Widgets/Controls/PreviewWindowControl.php @@ -0,0 +1,35 @@ +indentation() . $this->label . ':', + ]; + + $previewLines = is_array($this->value) ? $this->value : []; + + if ($previewLines === []) { + $previewLines = ['[unavailable]']; + } + + foreach ($previewLines as $previewLine) { + $lines[] = $this->indentation(1) . (string) $previewLine; + } + + return $lines; + } +} diff --git a/src/Editor/Widgets/Controls/SelectInputControl.php b/src/Editor/Widgets/Controls/SelectInputControl.php new file mode 100644 index 0000000..deed269 --- /dev/null +++ b/src/Editor/Widgets/Controls/SelectInputControl.php @@ -0,0 +1,53 @@ +moveSelection(1); + } + + public function decrement(): bool + { + return $this->moveSelection(-1); + } + + public function renderLines(): array + { + return [ + $this->indentation() . $this->label . ': <' . $this->formatScalarValue($this->value) . '>', + ]; + } + + private function moveSelection(int $offset): bool + { + if (!$this->isEditing || $this->options === []) { + return false; + } + + $currentIndex = array_search($this->value, $this->options, true); + $currentIndex = $currentIndex === false ? 0 : $currentIndex; + $nextIndex = ($currentIndex + $offset + count($this->options)) % count($this->options); + $this->value = $this->options[$nextIndex]; + + return true; + } +} diff --git a/src/Editor/Widgets/Controls/TextInputControl.php b/src/Editor/Widgets/Controls/TextInputControl.php new file mode 100644 index 0000000..ecd37a8 --- /dev/null +++ b/src/Editor/Widgets/Controls/TextInputControl.php @@ -0,0 +1,118 @@ +editingValue = (string) $this->value; + $this->cursorPosition = mb_strlen($this->editingValue); + + return true; + } + + public function commitEdit(): bool + { + if ($this->isEditing) { + $this->value = $this->transformCommittedValue($this->editingValue); + } + + return parent::commitEdit(); + } + + public function cancelEdit(): void + { + $this->editingValue = (string) $this->value; + $this->cursorPosition = mb_strlen($this->editingValue); + parent::cancelEdit(); + } + + public function handleInput(string $input): bool + { + if (!$this->isEditing || !$this->isPrintableInput($input)) { + return false; + } + + $beforeCursor = mb_substr($this->editingValue, 0, $this->cursorPosition); + $afterCursor = mb_substr($this->editingValue, $this->cursorPosition); + + $this->editingValue = $beforeCursor . $input . $afterCursor; + $this->cursorPosition++; + + return true; + } + + public function deleteBackward(): bool + { + if (!$this->isEditing || $this->cursorPosition <= 0) { + return false; + } + + $beforeCursor = mb_substr($this->editingValue, 0, $this->cursorPosition - 1); + $afterCursor = mb_substr($this->editingValue, $this->cursorPosition); + + $this->editingValue = $beforeCursor . $afterCursor; + $this->cursorPosition--; + + return true; + } + + public function moveCursorLeft(): bool + { + if (!$this->isEditing || $this->cursorPosition <= 0) { + return false; + } + + $this->cursorPosition--; + + return true; + } + + public function moveCursorRight(): bool + { + if (!$this->isEditing || $this->cursorPosition >= mb_strlen($this->editingValue)) { + return false; + } + + $this->cursorPosition++; + + return true; + } + + public function renderLines(): array + { + return [ + $this->indentation() . $this->label . ': ' . $this->getRenderedValue(), + ]; + } + + protected function getRenderedValue(): string + { + if (!$this->isEditing) { + return $this->formatScalarValue($this->value); + } + + $beforeCursor = mb_substr($this->editingValue, 0, $this->cursorPosition); + $atCursor = mb_substr($this->editingValue, $this->cursorPosition, 1); + $afterCursor = mb_substr($this->editingValue, $this->cursorPosition + ($atCursor === '' ? 0 : 1)); + + if ($atCursor === '') { + return $beforeCursor . '|'; + } + + return $beforeCursor . '|' . $atCursor . $afterCursor; + } + + protected function transformCommittedValue(string $editingValue): mixed + { + return $editingValue; + } +} diff --git a/src/Editor/Widgets/Controls/VectorInputControl.php b/src/Editor/Widgets/Controls/VectorInputControl.php new file mode 100644 index 0000000..8c90934 --- /dev/null +++ b/src/Editor/Widgets/Controls/VectorInputControl.php @@ -0,0 +1,47 @@ +normalizeVector($value); + $controls = []; + + foreach ($normalizedValue as $axis => $axisValue) { + $controls[] = new NumberInputControl( + strtoupper((string) $axis), + $axisValue, + $indentLevel + 1, + $isReadOnly, + ); + } + + parent::__construct($label, $normalizedValue, $controls, $indentLevel, $isReadOnly); + } + + private function normalizeVector(array $value): array + { + $normalizedValue = []; + + foreach ($value as $axis => $axisValue) { + if (!is_string($axis)) { + continue; + } + + $normalizedValue[$axis] = is_numeric($axisValue) ? $axisValue + 0 : 0; + } + + if ($normalizedValue === []) { + return ['x' => 0, 'y' => 0]; + } + + return $normalizedValue; + } +} diff --git a/src/Editor/Widgets/InspectorPanel.php b/src/Editor/Widgets/InspectorPanel.php index 5aef59f..304d915 100644 --- a/src/Editor/Widgets/InspectorPanel.php +++ b/src/Editor/Widgets/InspectorPanel.php @@ -2,11 +2,41 @@ namespace Sendama\Console\Editor\Widgets; -use Sendama\Console\Editor\Widgets\Widget; +use Atatusoft\Termutil\IO\Enumerations\Color; +use Sendama\Console\Editor\FocusTargetContext; +use Sendama\Console\Editor\IO\Enumerations\KeyCode; +use Sendama\Console\Editor\IO\Input; +use Sendama\Console\Editor\Widgets\Controls\CompoundInputControl; +use Sendama\Console\Editor\Widgets\Controls\InputControl; +use Sendama\Console\Editor\Widgets\Controls\InputControlFactory; +use Sendama\Console\Editor\Widgets\Controls\PreviewWindowControl; +use Sendama\Console\Editor\Widgets\Controls\TextInputControl; +use Sendama\Console\Editor\Widgets\Controls\VectorInputControl; class InspectorPanel extends Widget { + private const string STATE_CONTROL_SELECTION = 'control_selection'; + private const string STATE_PROPERTY_SELECTION = 'property_selection'; + private const string STATE_CONTROL_EDIT = 'control_edit'; + private const string SECTION_ICON = '▼'; + private const string SECTION_HEADER_SEQUENCE = "\033[30;47m"; + 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"; + protected ?array $inspectionTarget = null; + protected array $elements = []; + protected array $focusableControls = []; + protected ?int $selectedControlIndex = null; + protected array $lineKinds = []; + protected array $lineStates = []; + protected string $interactionState = self::STATE_CONTROL_SELECTION; + protected InputControlFactory $inputControlFactory; + protected ?TextInputControl $rendererTextureControl = null; + protected ?VectorInputControl $rendererOffsetControl = null; + protected ?VectorInputControl $rendererSizeControl = null; + protected ?PreviewWindowControl $rendererPreviewControl = null; public function __construct( array $position = ['x' => 135, 'y' => 1], @@ -15,31 +45,691 @@ public function __construct( ) { parent::__construct('Inspector', '', $position, $width, $height); + $this->inputControlFactory = new InputControlFactory(); } public function inspectTarget(?array $target): void { $this->inspectionTarget = $target; - $selectedItemType = $target['type'] ?? null; - $selectedItemName = $target['name'] ?? null; - $content = []; + $this->elements = []; + $this->focusableControls = []; + $this->selectedControlIndex = null; + $this->interactionState = self::STATE_CONTROL_SELECTION; + $this->rendererTextureControl = null; + $this->rendererOffsetControl = null; + $this->rendererSizeControl = null; + $this->rendererPreviewControl = null; - if ($selectedItemType !== null) { - $content[] = "Type: {$selectedItemType}"; + if ($target === null) { + $this->content = []; + $this->lineKinds = []; + $this->lineStates = []; + return; } - if ($selectedItemName !== null) { - $content[] = "Name: {$selectedItemName}"; + $context = $target['context'] ?? null; + $value = $target['value'] ?? null; + + if ($context === 'hierarchy' && is_array($value)) { + $this->buildHierarchyControls($target, $value); + } else { + $this->buildGenericControls($target); } - $this->content = $content; + if ($this->focusableControls !== []) { + $this->selectedControlIndex = 0; + $this->applyControlSelection(); + } + + $this->refreshContent(); + } + + public function focus(FocusTargetContext $context): void + { + parent::focus($context); + $this->interactionState = self::STATE_CONTROL_SELECTION; + + if ($this->selectedControlIndex === null && $this->focusableControls !== []) { + $this->selectedControlIndex = 0; + } + + $this->applyControlSelection(); + $this->refreshContent(); + } + + public function blur(FocusTargetContext $context): void + { + $this->resetInteractionState(); + parent::blur($context); + $this->refreshContent(); + } + + public function cycleFocusForward(): bool + { + if ($this->interactionState !== self::STATE_CONTROL_SELECTION || $this->focusableControls === []) { + return false; + } + + $this->moveControlSelection(1); + + return true; + } + + public function cycleFocusBackward(): bool + { + if ($this->interactionState !== self::STATE_CONTROL_SELECTION || $this->focusableControls === []) { + return false; + } + + $this->moveControlSelection(-1); + + return true; } - /** - * @inheritDoc - */ public function update(): void { - // TODO: Implement update() method. + if (!$this->hasFocus() || $this->selectedControlIndex === null) { + return; + } + + $selectedControl = $this->getSelectedControl(); + + if (!$selectedControl instanceof InputControl) { + return; + } + + match ($this->interactionState) { + self::STATE_CONTROL_SELECTION => $this->handleControlSelectionInput($selectedControl), + self::STATE_PROPERTY_SELECTION => $this->handlePropertySelectionInput($selectedControl), + self::STATE_CONTROL_EDIT => $this->handleControlEditInput($selectedControl), + default => null, + }; + + $selectedControl->update(); + $this->refreshContent(); + } + + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string + { + $contentIndex = $lineIndex - $this->padding->topPadding; + $lineKind = $this->lineKinds[$contentIndex] ?? null; + + if ($lineKind === 'section_header') { + return $this->decorateSectionHeaderLine($line, $contentColor, $lineIndex); + } + + $lineState = $this->lineStates[$contentIndex] ?? 'normal'; + + return match ($lineState) { + 'selected' => $this->decorateStatefulControlLine($line, $contentColor, $lineIndex, false), + 'editing' => $this->decorateStatefulControlLine($line, $contentColor, $lineIndex, true), + default => parent::decorateContentLine($line, $contentColor, $lineIndex), + }; + } + + private function decorateSectionHeaderLine(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; + + return $this->wrapWithColor($leftBorder, $borderColor) + . $this->wrapWithSequence($middle, self::SECTION_HEADER_SEQUENCE) + . $this->wrapWithColor($rightBorder, $borderColor); + } + + private function decorateStatefulControlLine( + string $line, + ?Color $contentColor, + int $lineIndex, + bool $isEditing, + ): 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; + $selectionSequence = match (true) { + $isEditing && $this->hasFocus() => self::EDITING_CONTROL_ACTIVE_SEQUENCE, + $isEditing => self::EDITING_CONTROL_SEQUENCE, + $this->hasFocus() => self::SELECTED_CONTROL_ACTIVE_SEQUENCE, + default => self::SELECTED_CONTROL_SEQUENCE, + }; + + return $this->wrapWithColor($leftBorder, $borderColor) + . $this->wrapWithSequence($middle, $selectionSequence) + . $this->wrapWithColor($rightBorder, $borderColor); + } + + private function buildHierarchyControls(array $target, array $item): void + { + $this->addControl(new TextInputControl('Type', $this->resolveDisplayType($target, $item), 0, true)); + $this->addControl(new TextInputControl('Name', $item['name'] ?? $target['name'] ?? 'Unnamed Object', 0)); + $this->addControl(new TextInputControl('Tag', $item['tag'] ?? 'None', 0)); + + $this->addSectionHeader('Transform'); + $this->addControl(new VectorInputControl('Position', $this->normalizeVector($item['position'] ?? null), 1)); + $this->addControl(new VectorInputControl('Rotation', $this->normalizeVector($item['rotation'] ?? null), 1)); + $this->addControl(new VectorInputControl('Scale', $this->normalizeVector($item['scale'] ?? ['x' => 1, 'y' => 1]), 1)); + + if (isset($item['size']) && is_array($item['size'])) { + $this->addControl(new VectorInputControl('Size', $this->normalizeVector($item['size']), 1)); + } + + $this->addSectionHeader('Renderer'); + $this->addRendererControls($item); + $this->addScriptComponents($item['components'] ?? []); + } + + private function buildGenericControls(array $target): void + { + if (isset($target['type'])) { + $this->addControl(new TextInputControl('Type', $target['type'], 0, true)); + } + + if (isset($target['name'])) { + $this->addControl(new TextInputControl('Name', $target['name'], 0, true)); + } + } + + private function addRendererControls(array $item): void + { + $sprite = is_array($item['sprite'] ?? null) ? $item['sprite'] : []; + $texture = is_array($sprite['texture'] ?? null) ? $sprite['texture'] : []; + $texturePath = is_string($texture['path'] ?? null) && $texture['path'] !== '' + ? $texture['path'] + : 'None'; + $offset = $this->normalizeVector($texture['position'] ?? null); + $size = $this->normalizeVector($texture['size'] ?? null); + + $this->rendererTextureControl = new TextInputControl('Texture', $texturePath, 1); + $this->rendererOffsetControl = new VectorInputControl('Offset', $offset, 1); + $this->rendererSizeControl = new VectorInputControl('Size', $size, 1); + $this->rendererPreviewControl = new PreviewWindowControl( + 'Preview', + $this->buildTexturePreviewLines($texturePath, $offset, $size), + 1, + ); + + $this->addControl($this->rendererTextureControl); + $this->addControl($this->rendererOffsetControl); + $this->addControl($this->rendererSizeControl); + $this->addControl($this->rendererPreviewControl); + + if (array_key_exists('text', $item)) { + $this->addControl(new TextInputControl('Text', $item['text'], 1)); + } + } + + private function addScriptComponents(mixed $components): void + { + if (!is_array($components)) { + return; + } + + foreach ($components as $component) { + if (!is_array($component)) { + continue; + } + + $this->addSectionHeader($this->resolveClassName($component['class'] ?? null, 'Component')); + + foreach ($component as $key => $value) { + if ($key === 'class') { + continue; + } + + $this->addControl($this->inputControlFactory->create( + $this->humanizeKey((string) $key), + $value, + 1, + )); + } + } + } + + private function addSectionHeader(string $title): void + { + $this->elements[] = [ + 'kind' => 'section_header', + 'text' => self::SECTION_ICON . ' ' . $title, + ]; + } + + private function addControl(InputControl $control): void + { + $this->elements[] = [ + 'kind' => 'control', + 'control' => $control, + ]; + $this->focusableControls[] = $control; + } + + private function refreshContent(): void + { + $this->refreshDerivedControls(); + $content = []; + $lineKinds = []; + $lineStates = []; + + foreach ($this->elements as $element) { + $kind = $element['kind'] ?? 'plain'; + + if ($kind === 'section_header') { + $content[] = $element['text'] ?? ''; + $lineKinds[] = 'section_header'; + $lineStates[] = 'normal'; + continue; + } + + $control = $element['control'] ?? null; + + if (!$control instanceof InputControl) { + continue; + } + + foreach ($control->renderLineDefinitions() as $lineDefinition) { + $content[] = $lineDefinition['text'] ?? ''; + $lineKinds[] = 'control'; + $lineStates[] = $lineDefinition['state'] ?? 'normal'; + } + } + + $this->content = $content; + $this->lineKinds = $lineKinds; + $this->lineStates = $lineStates; + } + + private function refreshDerivedControls(): void + { + if ( + !$this->rendererTextureControl instanceof TextInputControl + || !$this->rendererOffsetControl instanceof VectorInputControl + || !$this->rendererSizeControl instanceof VectorInputControl + || !$this->rendererPreviewControl instanceof PreviewWindowControl + ) { + return; + } + + $texturePath = (string) $this->rendererTextureControl->getValue(); + $offset = $this->rendererOffsetControl->getValue(); + $size = $this->rendererSizeControl->getValue(); + + if (!is_array($offset) || !is_array($size)) { + return; + } + + $this->rendererPreviewControl->setValue( + $this->buildTexturePreviewLines($texturePath, $offset, $size) + ); + } + + private function applyControlSelection(): void + { + foreach ($this->focusableControls as $index => $control) { + if ($index === $this->selectedControlIndex) { + $control->focus(); + continue; + } + + $control->blur(); + + if ($control instanceof CompoundInputControl) { + $control->endPropertySelection(); + } + + if ($control->isEditing()) { + $control->cancelEdit(); + } + } + } + + private function getSelectedControl(): ?InputControl + { + if ($this->selectedControlIndex === null) { + return null; + } + + return $this->focusableControls[$this->selectedControlIndex] ?? null; + } + + private function moveControlSelection(int $offset): void + { + if ($this->focusableControls === []) { + return; + } + + $this->selectedControlIndex ??= 0; + $this->selectedControlIndex = ($this->selectedControlIndex + $offset + count($this->focusableControls)) + % count($this->focusableControls); + $this->applyControlSelection(); + $this->refreshContent(); + } + + private function handleControlSelectionInput(InputControl $selectedControl): void + { + if (Input::isKeyDown(KeyCode::UP)) { + $this->moveControlSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->moveControlSelection(1); + return; + } + + if (!Input::isKeyDown(KeyCode::ENTER)) { + return; + } + + if ($selectedControl instanceof CompoundInputControl) { + if ($selectedControl->beginPropertySelection()) { + $this->interactionState = self::STATE_PROPERTY_SELECTION; + } + + return; + } + + if ($selectedControl->enterEditMode()) { + $this->interactionState = self::STATE_CONTROL_EDIT; + } + } + + private function handlePropertySelectionInput(InputControl $selectedControl): void + { + if (!$selectedControl instanceof CompoundInputControl) { + $this->interactionState = self::STATE_CONTROL_SELECTION; + return; + } + + if (Input::isKeyDown(KeyCode::ESCAPE)) { + $selectedControl->endPropertySelection(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $selectedControl->movePropertySelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $selectedControl->movePropertySelection(1); + return; + } + + if (Input::isKeyDown(KeyCode::ENTER) && $selectedControl->enterSelectedPropertyEdit()) { + $this->interactionState = self::STATE_CONTROL_EDIT; + } + } + + private function handleControlEditInput(InputControl $selectedControl): void + { + if (Input::isKeyDown(KeyCode::ENTER)) { + $this->commitSelectedEdit($selectedControl); + return; + } + + if (Input::isKeyDown(KeyCode::ESCAPE)) { + $this->cancelSelectedEdit($selectedControl); + return; + } + + if (Input::isKeyDown(KeyCode::BACKSPACE)) { + $selectedControl->deleteBackward(); + return; + } + + if (Input::isKeyDown(KeyCode::LEFT)) { + $selectedControl->moveCursorLeft(); + return; + } + + if (Input::isKeyDown(KeyCode::RIGHT)) { + $selectedControl->moveCursorRight(); + return; + } + + if (Input::isKeyDown(KeyCode::UP) && $selectedControl->increment()) { + return; + } + + if (Input::isKeyDown(KeyCode::DOWN) && $selectedControl->decrement()) { + return; + } + + $selectedControl->handleInput(Input::getCurrentInput()); + } + + private function commitSelectedEdit(InputControl $selectedControl): void + { + if ($selectedControl instanceof CompoundInputControl) { + $selectedControl->commitActiveEdit(); + $this->interactionState = self::STATE_PROPERTY_SELECTION; + return; + } + + $selectedControl->commitEdit(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + } + + private function cancelSelectedEdit(InputControl $selectedControl): void + { + if ($selectedControl instanceof CompoundInputControl) { + $selectedControl->cancelActiveEdit(); + $this->interactionState = self::STATE_PROPERTY_SELECTION; + return; + } + + $selectedControl->cancelEdit(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + } + + private function resetInteractionState(): void + { + $selectedControl = $this->getSelectedControl(); + + if ($selectedControl instanceof CompoundInputControl) { + if ($this->interactionState === self::STATE_CONTROL_EDIT) { + $selectedControl->cancelActiveEdit(); + } + + $selectedControl->endPropertySelection(); + } elseif ($selectedControl?->isEditing()) { + $selectedControl->cancelEdit(); + } + + $this->interactionState = self::STATE_CONTROL_SELECTION; + } + + private function buildTexturePreviewLines(string $texturePath, array $offset, array $size): array + { + if ($texturePath === 'None') { + return ['[unavailable]']; + } + + if ((int) $size['x'] <= 0 || (int) $size['y'] <= 0) { + return ['[unavailable]']; + } + + $resolvedTextureFilePath = $this->resolveTextureFilePath($texturePath); + + if ($resolvedTextureFilePath === null) { + return ['[missing texture]']; + } + + $textureContents = file_get_contents($resolvedTextureFilePath); + + if ($textureContents === false || $textureContents === '') { + return ['[empty texture]']; + } + + $textureRows = preg_split('/\R/u', rtrim($textureContents, "\r\n")); + + if ($textureRows === false) { + return ['[unavailable]']; + } + + if (count($textureRows) <= 1) { + $textureRows = $this->expandSingleLineTexture( + $textureRows[0] ?? '', + (int) $size['x'] + ); + } + + $previewWidth = (int) $size['x']; + $previewHeight = (int) $size['y']; + $offsetX = max(0, (int) $offset['x']); + $offsetY = max(0, (int) $offset['y']); + $previewLines = []; + + for ($rowIndex = 0; $rowIndex < $previewHeight; $rowIndex++) { + $sourceRow = $textureRows[$offsetY + $rowIndex] ?? ''; + $previewLine = mb_substr($sourceRow, $offsetX, $previewWidth); + + if ($previewLine === '') { + $previewLine = str_repeat(' ', $previewWidth); + } + + $previewLines[] = $previewLine; + } + + return $previewLines === [] ? ['[unavailable]'] : $previewLines; + } + + private function resolveDisplayType(array $target, array $item): string + { + $displayType = $target['type'] ?? null; + + if (is_string($displayType) && $displayType !== '') { + return $displayType; + } + + return $this->resolveClassName($item['type'] ?? null, 'Unknown'); + } + + private function resolveClassName(mixed $classReference, string $default = 'Unknown'): string + { + if (!is_string($classReference) || $classReference === '') { + return $default; + } + + $normalizedClassReference = ltrim($classReference, '\\'); + $normalizedClassReference = preg_replace('/::class$/', '', $normalizedClassReference) + ?? $normalizedClassReference; + $classSegments = explode('\\', $normalizedClassReference); + + return end($classSegments) ?: $default; + } + + private function resolveTextureFilePath(string $texturePath): ?string + { + $normalizedTexturePath = str_replace('\\', '/', $texturePath); + $candidatePaths = []; + + if ($this->hasFileExtension($normalizedTexturePath)) { + $candidatePaths[] = $normalizedTexturePath; + } else { + $candidatePaths[] = $normalizedTexturePath . '.texture'; + } + + $workingDirectory = getcwd() ?: '.'; + $assetsRoots = [ + $workingDirectory, + $workingDirectory . '/Assets', + $workingDirectory . '/assets', + ]; + + foreach ($assetsRoots as $assetsRoot) { + $trimmedAssetsRoot = rtrim($assetsRoot, '/'); + + foreach ($candidatePaths as $candidatePath) { + $resolvedPath = $trimmedAssetsRoot . '/' . ltrim($candidatePath, '/'); + + if (is_file($resolvedPath)) { + return $resolvedPath; + } + } + } + + return null; + } + + private function hasFileExtension(string $path): bool + { + return pathinfo($path, PATHINFO_EXTENSION) !== ''; + } + + private function normalizeVector(mixed $value): array + { + if (!is_array($value)) { + return ['x' => 0, 'y' => 0]; + } + + return [ + 'x' => $this->normalizeNumericValue($value['x'] ?? 0), + 'y' => $this->normalizeNumericValue($value['y'] ?? 0), + ]; + } + + private function normalizeNumericValue(mixed $value): int|float + { + if (is_int($value) || is_float($value)) { + return $value; + } + + if (is_numeric($value)) { + return str_contains((string) $value, '.') ? (float) $value : (int) $value; + } + + return 0; + } + + private function expandSingleLineTexture(string $textureContents, int $rowWidth): array + { + if ($textureContents === '') { + return []; + } + + $characters = preg_split('//u', $textureContents, -1, PREG_SPLIT_NO_EMPTY); + + if ($characters === false || $characters === []) { + return []; + } + + if ($rowWidth <= 1) { + return $characters; + } + + $rows = []; + + for ($index = 0; $index < count($characters); $index += $rowWidth) { + $rows[] = implode('', array_slice($characters, $index, $rowWidth)); + } + + return $rows; + } + + private function humanizeKey(string $key): string + { + $spacedKey = preg_replace('/(? 37, 'y' => 1], @@ -82,7 +88,7 @@ public function handleMouseClick(int $x, int $y): void } $tabStart = $currentX; - $tabEnd = $tabStart + strlen($tabTitle) - 1; + $tabEnd = $tabStart + mb_strlen($tabTitle) - 1; if ($x >= $tabStart && $x <= $tabEnd) { $this->activeTabIndex = $index; @@ -94,10 +100,40 @@ public function handleMouseClick(int $x, int $y): void } } + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string + { + if ($lineIndex !== 1) { + return parent::decorateContentLine($line, $contentColor, $lineIndex); + } + + $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 refreshContent(): void { $tabsLine = ''; - $activeTabOffset = 0; + $this->activeTabOffset = 0; foreach (self::TAB_TITLES as $index => $tabTitle) { if ($index > 0) { @@ -105,25 +141,38 @@ private function refreshContent(): void } if ($index === $this->activeTabIndex) { - $activeTabOffset = strlen($tabsLine); + $this->activeTabOffset = mb_strlen($tabsLine); } $tabsLine .= $tabTitle; } $dividerWidth = max(0, $this->innerWidth - 2); - $dividerLine = str_repeat('-', $dividerWidth); $activeTabTitle = self::TAB_TITLES[$this->activeTabIndex]; + $this->activeTabLength = mb_strlen($activeTabTitle); + $dividerLine = $this->buildDividerLine($dividerWidth); - if ($dividerWidth > 0) { - $dividerLine = substr_replace( - $dividerLine, - str_repeat('=', strlen($activeTabTitle)), - $activeTabOffset, - strlen($activeTabTitle) - ); + $this->content = [$tabsLine, $dividerLine]; + } + + private function buildDividerLine(int $dividerWidth): string + { + if ($dividerWidth <= 0) { + return ''; } - $this->content = [$tabsLine, $dividerLine]; + $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); } } diff --git a/src/Editor/Widgets/Widget.php b/src/Editor/Widgets/Widget.php index d183b04..ffbdaed 100644 --- a/src/Editor/Widgets/Widget.php +++ b/src/Editor/Widgets/Widget.php @@ -12,6 +12,11 @@ */ abstract class Widget extends Window implements FocusableInterface { + protected ?Widget $topSibling = null; + protected ?Widget $rightSibling = null; + protected ?Widget $bottomSibling = null; + protected ?Widget $leftSibling = null; + /** * @var int */ @@ -101,6 +106,80 @@ public function handleMouseClick(int $x, int $y): void { } + public function cycleFocusForward(): bool + { + return false; + } + + public function cycleFocusBackward(): bool + { + return false; + } + + public function setTopSibling(?Widget $widget): void + { + $this->topSibling = $widget; + } + + public function getTopSibling(): ?Widget + { + return $this->topSibling; + } + + public function setRightSibling(?Widget $widget): void + { + $this->rightSibling = $widget; + } + + public function getRightSibling(): ?Widget + { + return $this->rightSibling; + } + + public function setBottomSibling(?Widget $widget): void + { + $this->bottomSibling = $widget; + } + + public function getBottomSibling(): ?Widget + { + return $this->bottomSibling; + } + + public function setLeftSibling(?Widget $widget): void + { + $this->leftSibling = $widget; + } + + public function getLeftSibling(): ?Widget + { + return $this->leftSibling; + } + + public function setSiblings( + ?Widget $top = null, + ?Widget $right = null, + ?Widget $bottom = null, + ?Widget $left = null, + ): void + { + $this->topSibling = $top; + $this->rightSibling = $right; + $this->bottomSibling = $bottom; + $this->leftSibling = $left; + } + + public function getSibling(string $direction): ?Widget + { + return match ($direction) { + 'top' => $this->topSibling, + 'right' => $this->rightSibling, + 'bottom' => $this->bottomSibling, + 'left' => $this->leftSibling, + default => null, + }; + } + protected function getContentAreaTop(): int { return ($this->position['y'] ?? 0) + 1; diff --git a/tests/Unit/ConsolePanelTest.php b/tests/Unit/ConsolePanelTest.php new file mode 100644 index 0000000..3b91b2c --- /dev/null +++ b/tests/Unit/ConsolePanelTest.php @@ -0,0 +1,108 @@ +content)->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 scrolls upward through older log lines', 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, [ + 'line 1', + 'line 2', + 'line 3', + 'line 4', + 'line 5', + ]) . PHP_EOL + ); + + $panel = new ConsolePanel( + width: 40, + height: 6, + logFilePath: $workspace . '/logs/debug.log', + ); + + expect($panel->content)->toBe([ + 'line 3', + 'line 4', + 'line 5', + ]); + + $panel->scrollUp(); + + expect($panel->content)->toBe([ + 'line 2', + 'line 3', + 'line 4', + 'line 5', + ]); + + $panel->scrollUp(); + $panel->scrollUp(); + + expect($panel->content)->toBe([ + 'line 1', + 'line 2', + 'line 3', + 'line 4', + ]); +}); + +test('console panel scrolls down until the last log line is at the top', 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, [ + 'line 1', + 'line 2', + 'line 3', + 'line 4', + 'line 5', + ]) . PHP_EOL + ); + + $panel = new ConsolePanel( + width: 40, + height: 6, + logFilePath: $workspace . '/logs/debug.log', + ); + + $panel->scrollDown(); + $panel->scrollDown(); + $panel->scrollDown(); + $panel->scrollDown(); + + expect($panel->content)->toBe([ + 'line 5', + ]); +}); diff --git a/tests/Unit/InputManagerTest.php b/tests/Unit/InputManagerTest.php new file mode 100644 index 0000000..07a570f --- /dev/null +++ b/tests/Unit/InputManagerTest.php @@ -0,0 +1,44 @@ +setAccessible(true); + + expect($getKey->invoke(null, "\033[Z"))->toBe(KeyCode::SHIFT_TAB->value); + expect($getKey->invoke(null, "\033[1;2Z"))->toBe(KeyCode::SHIFT_TAB->value); +}); + +test('input manager normalizes shift up sequences', function () { + $getKey = new ReflectionMethod(InputManager::class, 'getKey'); + $getKey->setAccessible(true); + + expect($getKey->invoke(null, "\033[1;2A"))->toBe(KeyCode::SHIFT_UP->value); + expect($getKey->invoke(null, "\033[a"))->toBe(KeyCode::SHIFT_UP->value); +}); + +test('input manager normalizes shift down sequences', function () { + $getKey = new ReflectionMethod(InputManager::class, 'getKey'); + $getKey->setAccessible(true); + + expect($getKey->invoke(null, "\033[1;2B"))->toBe(KeyCode::SHIFT_DOWN->value); + expect($getKey->invoke(null, "\033[b"))->toBe(KeyCode::SHIFT_DOWN->value); +}); + +test('input manager normalizes shift right sequences', function () { + $getKey = new ReflectionMethod(InputManager::class, 'getKey'); + $getKey->setAccessible(true); + + expect($getKey->invoke(null, "\033[1;2C"))->toBe(KeyCode::SHIFT_RIGHT->value); + expect($getKey->invoke(null, "\033[c"))->toBe(KeyCode::SHIFT_RIGHT->value); +}); + +test('input manager normalizes shift left sequences', function () { + $getKey = new ReflectionMethod(InputManager::class, 'getKey'); + $getKey->setAccessible(true); + + expect($getKey->invoke(null, "\033[1;2D"))->toBe(KeyCode::SHIFT_LEFT->value); + expect($getKey->invoke(null, "\033[d"))->toBe(KeyCode::SHIFT_LEFT->value); +}); diff --git a/tests/Unit/InspectorPanelTest.php b/tests/Unit/InspectorPanelTest.php new file mode 100644 index 0000000..0ad3940 --- /dev/null +++ b/tests/Unit/InspectorPanelTest.php @@ -0,0 +1,166 @@ +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], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 1, 'y' => 1], + 'size' => ['x' => 2, 'y' => 2], + ], + ], + 'components' => [ + ['class' => 'Sendama\\Blasters\\Scripts\\Player\\PlayerController'], + [ + 'class' => 'Sendama\\Blasters\\Scripts\\Weapon\\Gun', + 'ammo' => 30, + ], + ], + ], + ]); + + expect($panel->content)->toBe([ + 'Type: GameObject', + 'Name: Player', + 'Tag: Player', + '▼ Transform', + ' Position:', + ' X: 4', + ' Y: 12', + ' Rotation:', + ' X: 0', + ' Y: 0', + ' Scale:', + ' X: 1', + ' Y: 1', + '▼ Renderer', + ' Texture: Textures/player', + ' Offset:', + ' X: 1', + ' Y: 1', + ' Size:', + ' X: 2', + ' Y: 2', + ' Preview:', + ' fg', + ' jk', + '▼ PlayerController', + '▼ Gun', + ' Ammo: 30', + ]); + } finally { + if ($originalWorkingDirectory !== false) { + chdir($originalWorkingDirectory); + } + } +}); + +test('inspector panel styles component headers with a white 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', + 'components' => [], + ], + ]); + + $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;47m"); +}); + +test('inspector panel cycles focus through controls within the panel', function () { + $panelWidth = 32; + $panel = new InspectorPanel(width: $panelWidth, height: 12); + + $panel->inspectTarget([ + 'context' => 'asset', + 'name' => 'Textures', + 'type' => 'Folder', + 'value' => ['name' => 'Textures'], + ]); + + $decorateContentLine = new ReflectionMethod($panel, 'decorateContentLine'); + $decorateContentLine->setAccessible(true); + + $typeLine = '|' . str_pad($panel->content[0], $panelWidth - 2) . '|'; + $renderedTypeLine = $decorateContentLine->invoke($panel, $typeLine, null, 0); + + expect($renderedTypeLine)->toContain("\033[30;46m"); + + $panel->cycleFocusForward(); + + $nameLine = '|' . str_pad($panel->content[1], $panelWidth - 2) . '|'; + $renderedNameLine = $decorateContentLine->invoke($panel, $nameLine, null, 1); + + expect($renderedNameLine)->toContain("\033[30;46m"); +}); + +test('inspector panel cycles focus backward through controls within the panel', function () { + $panelWidth = 32; + $panel = new InspectorPanel(width: $panelWidth, height: 12); + + $panel->inspectTarget([ + 'context' => 'asset', + 'name' => 'Textures', + 'type' => 'Folder', + 'value' => ['name' => 'Textures'], + ]); + + $decorateContentLine = new ReflectionMethod($panel, 'decorateContentLine'); + $decorateContentLine->setAccessible(true); + + $panel->cycleFocusBackward(); + + $nameLine = '|' . str_pad($panel->content[1], $panelWidth - 2) . '|'; + $renderedNameLine = $decorateContentLine->invoke($panel, $nameLine, null, 1); + + expect($renderedNameLine)->toContain("\033[30;46m"); +}); + +test('inspector panel keeps generic asset inspection simple', function () { + $panel = new InspectorPanel(width: 32, height: 12); + + $panel->inspectTarget([ + 'context' => 'asset', + 'name' => 'Textures', + 'type' => 'Folder', + 'value' => ['name' => 'Textures'], + ]); + + expect($panel->content)->toBe([ + 'Type: Folder', + 'Name: Textures', + ]); +}); diff --git a/tests/Unit/MainPanelTest.php b/tests/Unit/MainPanelTest.php index 164e001..5867526 100644 --- a/tests/Unit/MainPanelTest.php +++ b/tests/Unit/MainPanelTest.php @@ -31,5 +31,6 @@ $panel->selectTab('Sprite'); expect($panel->content[0])->toContain('Scene Game Sprite'); - expect($panel->content[1])->toContain('======'); + expect($panel->content[1])->toContain('■■■■■■'); + expect(mb_strlen($panel->content[1]))->toBe($panel->innerWidth - 2); }); diff --git a/tests/Unit/TextInputControlTest.php b/tests/Unit/TextInputControlTest.php new file mode 100644 index 0000000..42e408e --- /dev/null +++ b/tests/Unit/TextInputControlTest.php @@ -0,0 +1,15 @@ +enterEditMode())->toBeTrue(); + expect($control->handleInput('1'))->toBeTrue(); + expect($control->moveCursorLeft())->toBeTrue(); + expect($control->handleInput('X'))->toBeTrue(); + expect($control->deleteBackward())->toBeTrue(); + expect($control->commitEdit())->toBeTrue(); + expect($control->getValue())->toBe('Player1'); +}); diff --git a/tests/Unit/VectorInputControlTest.php b/tests/Unit/VectorInputControlTest.php new file mode 100644 index 0000000..534e3c4 --- /dev/null +++ b/tests/Unit/VectorInputControlTest.php @@ -0,0 +1,22 @@ + 4, 'y' => 12]); + + expect($control->beginPropertySelection())->toBeTrue(); + expect($control->enterSelectedPropertyEdit())->toBeTrue(); + expect($control->handleInput('6'))->toBeTrue(); + expect($control->increment())->toBeTrue(); + expect($control->commitActiveEdit())->toBeTrue(); + expect($control->movePropertySelection(1))->toBeTrue(); + expect($control->enterSelectedPropertyEdit())->toBeTrue(); + expect($control->decrement())->toBeTrue(); + expect($control->commitActiveEdit())->toBeTrue(); + expect($control->renderLines())->toBe([ + ' Position:', + ' X: 47', + ' Y: 11', + ]); +}); diff --git a/tests/Unit/WidgetTest.php b/tests/Unit/WidgetTest.php new file mode 100644 index 0000000..648c154 --- /dev/null +++ b/tests/Unit/WidgetTest.php @@ -0,0 +1,48 @@ + 1, 'y' => 1], 20, 10); + } + + public function update(): void + { + } + }; + $main = new class extends Widget { + public function __construct() + { + parent::__construct('Main', '', ['x' => 22, 'y' => 1], 40, 10); + } + + public function update(): void + { + } + }; + $assets = new class extends Widget { + public function __construct() + { + parent::__construct('Assets', '', ['x' => 1, 'y' => 12], 20, 10); + } + + public function update(): void + { + } + }; + + $hierarchy->setSiblings( + top: null, + right: $main, + bottom: $assets, + left: null, + ); + + expect($hierarchy->getSibling('top'))->toBeNull(); + expect($hierarchy->getSibling('right'))->toBe($main); + expect($hierarchy->getSibling('bottom'))->toBe($assets); + expect($hierarchy->getSibling('left'))->toBeNull(); +}); From d8f66c05a25d0fcc5faca4f093e9503e506167cb Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 12 Mar 2026 00:56:13 +0200 Subject: [PATCH 6/6] feat(editor): add FileDialogModal and PathInputControl; enhance InspectorPanel with path input handling --- src/Editor/Editor.php | 67 ++- src/Editor/IO/Enumerations/KeyCode.php | 1 + .../Widgets/Controls/PathInputControl.php | 32 ++ src/Editor/Widgets/FileDialogModal.php | 427 ++++++++++++++++++ src/Editor/Widgets/InspectorPanel.php | 231 +++++++++- src/Editor/Widgets/MainPanel.php | 165 ++++++- src/Editor/Widgets/OptionListModal.php | 161 +++++++ src/Editor/Widgets/Widget.php | 188 +++++++- tests/Unit/FileDialogModalTest.php | 65 +++ tests/Unit/InputManagerTest.php | 7 + tests/Unit/InspectorPanelTest.php | 61 +++ tests/Unit/MainPanelTest.php | 42 ++ tests/Unit/PathInputControlTest.php | 12 + tests/Unit/WidgetTest.php | 51 +++ 14 files changed, 1487 insertions(+), 23 deletions(-) create mode 100644 src/Editor/Widgets/Controls/PathInputControl.php create mode 100644 src/Editor/Widgets/FileDialogModal.php create mode 100644 src/Editor/Widgets/OptionListModal.php create mode 100644 tests/Unit/FileDialogModalTest.php create mode 100644 tests/Unit/PathInputControlTest.php diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php index d8b6f63..24f8c95 100644 --- a/src/Editor/Editor.php +++ b/src/Editor/Editor.php @@ -129,6 +129,7 @@ final class Editor implements ObservableInterface protected int $terminalHeight = DEFAULT_TERMINAL_HEIGHT; protected PanelListModal $panelListModal; protected bool $shouldRefreshBackgroundUnderModal = false; + protected bool $didRenderOverlayLastFrame = false; /** * @param string $name @@ -325,6 +326,12 @@ public function setState(EditorStateInterface $editorState): void $this->editorState?->exit($context); $this->editorState = $editorState; $this->editorState->enter($context); + $this->syncPlayModeState(); + + if ($editorState instanceof PlayState) { + $this->mainPanel->selectTab('Game'); + $this->setFocusedPanel($this->mainPanel); + } } /** @@ -359,7 +366,7 @@ private function update(): void return; } - $this->consolePanel->setPlayModeActive($this->editorState instanceof PlayState); + $this->syncPlayModeState(); foreach ($this->panels as $panel) { $panel->update(); @@ -374,6 +381,8 @@ private function render(): void { $this->frameCount++; if ($this->panelListModal->isVisible()) { + $this->didRenderOverlayLastFrame = true; + if ($this->shouldRefreshBackgroundUnderModal) { $this->renderEditorFrame(); } @@ -388,6 +397,26 @@ private function render(): void return; } + if ($this->focusedPanel?->hasActiveModal()) { + $this->didRenderOverlayLastFrame = true; + $this->focusedPanel->syncModalLayout($this->terminalWidth, $this->terminalHeight); + + if ($this->shouldRefreshBackgroundUnderModal || $this->focusedPanel->isModalDirty()) { + $this->renderEditorFrame(); + $this->focusedPanel->renderActiveModal(); + $this->focusedPanel->markModalClean(); + $this->shouldRefreshBackgroundUnderModal = false; + } + + $this->notify(new EditorEvent(EventType::EDITOR_RENDERED->value, $this)); + return; + } + + if ($this->didRenderOverlayLastFrame) { + Console::clear(); + $this->didRenderOverlayLastFrame = false; + } + $this->shouldRefreshBackgroundUnderModal = false; $this->renderEditorFrame(); @@ -488,6 +517,30 @@ private function initializeEditorStates(): void $this->setState($this->editState); } + private function togglePlayMode(): void + { + if ($this->editorState instanceof PlayState) { + $this->setState($this->editState); + } else { + $this->setState($this->playState); + } + + $this->shouldRefreshBackgroundUnderModal = true; + } + + private function syncPlayModeState(): void + { + $isPlayModeActive = $this->editorState instanceof PlayState; + + if (isset($this->consolePanel)) { + $this->consolePanel->setPlayModeActive($isPlayModeActive); + } + + if (isset($this->mainPanel)) { + $this->mainPanel->setPlayModeActive($isPlayModeActive); + } + } + /** * @return void * @throws SendamaConsoleException @@ -589,6 +642,11 @@ private function createFocusTargetContext(): FocusTargetContext private function handlePanelKeyboardWorkflow(): void { + if (Input::isKeyDown(IO\Enumerations\KeyCode::PLAY_TOGGLE, false)) { + $this->togglePlayMode(); + return; + } + if ($this->panelListModal->isVisible()) { $this->handlePanelListModalInput(); return; @@ -599,6 +657,10 @@ private function handlePanelKeyboardWorkflow(): void return; } + if ($this->focusedPanel?->hasActiveModal()) { + return; + } + if (Input::isKeyDown(IO\Enumerations\KeyCode::SHIFT_UP)) { $this->focusSiblingPanel('top'); return; @@ -653,7 +715,7 @@ private function refreshTerminalSize(bool $force = false): void $this->layoutPanels(); - if ($this->panelListModal->isVisible()) { + if ($this->panelListModal->isVisible() || $this->focusedPanel?->hasActiveModal()) { $this->shouldRefreshBackgroundUnderModal = true; } } @@ -687,6 +749,7 @@ private function layoutPanels(): void $this->inspectorPanel->setDimensions($rightPanelWidth, $availableHeight); $this->panelListModal->syncLayout($this->terminalWidth, $this->terminalHeight); + $this->focusedPanel?->syncModalLayout($this->terminalWidth, $this->terminalHeight); } private function configurePanelGraph(): void diff --git a/src/Editor/IO/Enumerations/KeyCode.php b/src/Editor/IO/Enumerations/KeyCode.php index 4d1bed6..4f9257f 100644 --- a/src/Editor/IO/Enumerations/KeyCode.php +++ b/src/Editor/IO/Enumerations/KeyCode.php @@ -56,6 +56,7 @@ enum KeyCode: string case SELECT = 'select'; case PAGE_UP = 'page_up'; case PAGE_DOWN = 'page_down'; + case PLAY_TOGGLE = '%'; case a = 'a'; case b = 'b'; case c = 'c'; diff --git a/src/Editor/Widgets/Controls/PathInputControl.php b/src/Editor/Widgets/Controls/PathInputControl.php new file mode 100644 index 0000000..4ebaf49 --- /dev/null +++ b/src/Editor/Widgets/Controls/PathInputControl.php @@ -0,0 +1,32 @@ +workingDirectory = Path::normalize($workingDirectory); + } + + public function getWorkingDirectory(): string + { + return $this->workingDirectory; + } + + public function setValueFromRelativePath(string $relativePath): void + { + $normalizedPath = str_replace('\\', '/', $relativePath); + $normalizedPath = ltrim($normalizedPath, '/'); + $this->setValue($normalizedPath); + } +} diff --git a/src/Editor/Widgets/FileDialogModal.php b/src/Editor/Widgets/FileDialogModal.php new file mode 100644 index 0000000..fb46129 --- /dev/null +++ b/src/Editor/Widgets/FileDialogModal.php @@ -0,0 +1,427 @@ + 1, 'y' => 1], + width: 48, + height: 16, + ); + } + + public function show(string $workingDirectory, ?string $selectedRelativePath = null): void + { + $this->workingDirectory = Path::normalize($workingDirectory); + $this->entryTree = $this->buildEntryTree($this->workingDirectory); + $this->expandedPaths = []; + $this->selectedPath = null; + $this->isVisible = true; + $this->refreshContent(); + $this->markDirty(); + + if (is_string($selectedRelativePath) && $selectedRelativePath !== '') { + $this->selectRelativePath($selectedRelativePath); + } + } + + 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 moveSelection(int $offset): void + { + if ($this->visibleEntries === []) { + return; + } + + $selectedIndex = $this->getSelectedVisibleIndex() ?? 0; + $nextIndex = max(0, min($selectedIndex + $offset, count($this->visibleEntries) - 1)); + $this->selectedPath = $this->visibleEntries[$nextIndex]['path'] ?? $this->selectedPath; + $this->refreshContent(); + } + + public function expandSelection(): void + { + $selectedEntry = $this->getSelectedVisibleEntry(); + + if (!$selectedEntry || !($selectedEntry['isDirectory'] ?? false)) { + return; + } + + if (!($selectedEntry['isExpanded'] ?? false)) { + $this->expandedPaths[$selectedEntry['path']] = true; + $this->refreshContent(); + return; + } + + $selectedDepth = $selectedEntry['depth'] ?? 0; + $selectedPath = $selectedEntry['path'] ?? ''; + + foreach ($this->visibleEntries as $entry) { + if ( + str_starts_with((string) ($entry['path'] ?? ''), $selectedPath . '.') + && ($entry['depth'] ?? -1) === $selectedDepth + 1 + ) { + $this->selectedPath = $entry['path']; + $this->refreshContent(); + return; + } + } + } + + public function collapseSelection(): void + { + $selectedEntry = $this->getSelectedVisibleEntry(); + + if (!$selectedEntry) { + return; + } + + if (($selectedEntry['isDirectory'] ?? false) && ($selectedEntry['isExpanded'] ?? false)) { + unset($this->expandedPaths[$selectedEntry['path']]); + $this->refreshContent(); + return; + } + + $parentPath = $this->getParentPath((string) $selectedEntry['path']); + + if ($parentPath === null) { + return; + } + + $this->selectedPath = $parentPath; + $this->refreshContent(); + } + + public function submitSelection(): ?string + { + $selectedEntry = $this->getSelectedVisibleEntry(); + + if (!$selectedEntry) { + return null; + } + + if ($selectedEntry['isDirectory'] ?? false) { + return null; + } + + return $selectedEntry['item']['relativePath'] ?? null; + } + + public function syncLayout(int $terminalWidth, int $terminalHeight): void + { + $desiredWidth = max( + 36, + intdiv($terminalWidth * 2, 3), + mb_strlen($this->title) + 4, + mb_strlen($this->help) + 4, + ); + $modalWidth = min($desiredWidth, max(3, $terminalWidth - 2)); + $modalHeight = min(max(10, intdiv($terminalHeight * 2, 3)), 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 + { + } + + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string + { + $selectedVisibleIndex = $this->getSelectedVisibleIndex(); + $selectedLineIndex = $selectedVisibleIndex === null + ? null + : $this->padding->topPadding + $selectedVisibleIndex; + + if ($lineIndex !== $selectedLineIndex) { + return parent::decorateContentLine($line, $contentColor, $lineIndex); + } + + $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); + + return $this->wrapWithColor($leftBorder, $contentColor) + . $this->wrapWithSequence($middle, self::SELECTED_ROW_SEQUENCE) + . $this->wrapWithColor($rightBorder, $contentColor); + } + + private function buildEntryTree(string $directory): array + { + if (!is_dir($directory)) { + return []; + } + + $entries = scandir($directory); + + if ($entries === false) { + return []; + } + + $tree = []; + + foreach ($entries as $entryName) { + if ($entryName === '.' || $entryName === '..') { + continue; + } + + $entryPath = Path::join($directory, $entryName); + $isDirectory = is_dir($entryPath); + + $tree[] = [ + 'name' => $entryName, + 'absolutePath' => $entryPath, + 'relativePath' => $this->buildRelativePath($entryPath), + 'isDirectory' => $isDirectory, + 'children' => $isDirectory ? $this->buildEntryTree($entryPath) : [], + ]; + } + + usort($tree, function (array $left, array $right) { + if (($left['isDirectory'] ?? false) !== ($right['isDirectory'] ?? false)) { + return ($left['isDirectory'] ?? false) ? -1 : 1; + } + + return strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? '')); + }); + + return $tree; + } + + private function buildRelativePath(string $absolutePath): string + { + $relativePath = substr($absolutePath, strlen($this->workingDirectory)); + + return ltrim((string) $relativePath, DIRECTORY_SEPARATOR); + } + + private function refreshContent(): void + { + $this->visibleEntries = $this->buildVisibleEntries($this->entryTree); + $this->syncSelectedPath(); + $this->content = array_map(fn(array $entry) => $this->formatVisibleEntry($entry), $this->visibleEntries); + $this->markDirty(); + } + + private function buildVisibleEntries(array $items, int $depth = 0, string $parentPath = ''): array + { + $visibleEntries = []; + + foreach (array_values($items) as $index => $item) { + if (!is_array($item)) { + continue; + } + + $path = $parentPath === '' ? (string) $index : $parentPath . '.' . $index; + $isDirectory = (bool) ($item['isDirectory'] ?? false); + $isExpanded = $isDirectory && isset($this->expandedPaths[$path]); + + $visibleEntries[] = [ + 'path' => $path, + 'item' => $item, + 'depth' => $depth, + 'isDirectory' => $isDirectory, + 'isExpanded' => $isExpanded, + ]; + + if ($isExpanded) { + $visibleEntries = [ + ...$visibleEntries, + ...$this->buildVisibleEntries($item['children'] ?? [], $depth + 1, $path), + ]; + } + } + + return $visibleEntries; + } + + private function syncSelectedPath(): void + { + if ($this->selectedPath !== null && $this->findVisibleIndexByPath($this->selectedPath) !== null) { + return; + } + + $candidatePath = $this->selectedPath; + + while ($candidatePath !== null) { + $candidatePath = $this->getParentPath($candidatePath); + + if ($candidatePath !== null && $this->findVisibleIndexByPath($candidatePath) !== null) { + $this->selectedPath = $candidatePath; + return; + } + } + + $this->selectedPath = $this->visibleEntries[0]['path'] ?? null; + } + + private function getSelectedVisibleIndex(): ?int + { + return $this->findVisibleIndexByPath($this->selectedPath); + } + + private function getSelectedVisibleEntry(): ?array + { + $selectedIndex = $this->getSelectedVisibleIndex(); + + if ($selectedIndex === null) { + return null; + } + + return $this->visibleEntries[$selectedIndex] ?? null; + } + + private function findVisibleIndexByPath(?string $path): ?int + { + if ($path === null) { + return null; + } + + foreach ($this->visibleEntries as $index => $entry) { + if (($entry['path'] ?? null) === $path) { + return $index; + } + } + + return null; + } + + private function formatVisibleEntry(array $entry): string + { + $icon = match (true) { + ($entry['isDirectory'] ?? false) && ($entry['isExpanded'] ?? false) => self::EXPANDED_ICON, + ($entry['isDirectory'] ?? false) => self::COLLAPSED_ICON, + default => self::LEAF_ICON, + }; + $name = $entry['item']['name'] ?? 'Unnamed'; + $indentation = str_repeat(' ', (int) ($entry['depth'] ?? 0)); + + return $indentation . $icon . ' ' . $name; + } + + private function getParentPath(string $path): ?string + { + $separatorPosition = strrpos($path, '.'); + + if ($separatorPosition === false) { + return null; + } + + return substr($path, 0, $separatorPosition); + } + + private function selectRelativePath(string $relativePath): void + { + $normalizedTarget = str_replace('\\', '/', $relativePath); + $matchedPath = $this->findEntryPathByRelativePath($this->entryTree, $normalizedTarget); + + if ($matchedPath === null) { + return; + } + + $this->selectedPath = $matchedPath; + $this->refreshContent(); + } + + private function findEntryPathByRelativePath( + array $items, + string $targetRelativePath, + string $parentPath = '' + ): ?string + { + foreach (array_values($items) as $index => $item) { + if (!is_array($item)) { + continue; + } + + $path = $parentPath === '' ? (string) $index : $parentPath . '.' . $index; + $entryRelativePath = str_replace('\\', '/', (string) ($item['relativePath'] ?? '')); + + if ($entryRelativePath === $targetRelativePath) { + return $path; + } + + $children = $item['children'] ?? []; + + if (!is_array($children) || $children === []) { + continue; + } + + $childPath = $this->findEntryPathByRelativePath($children, $targetRelativePath, $path); + + if ($childPath !== null) { + $this->expandedPaths[$path] = true; + return $childPath; + } + } + + return null; + } + + private function markDirty(): void + { + $this->isDirty = true; + } +} diff --git a/src/Editor/Widgets/InspectorPanel.php b/src/Editor/Widgets/InspectorPanel.php index 304d915..ba603a0 100644 --- a/src/Editor/Widgets/InspectorPanel.php +++ b/src/Editor/Widgets/InspectorPanel.php @@ -9,6 +9,7 @@ use Sendama\Console\Editor\Widgets\Controls\CompoundInputControl; use Sendama\Console\Editor\Widgets\Controls\InputControl; use Sendama\Console\Editor\Widgets\Controls\InputControlFactory; +use Sendama\Console\Editor\Widgets\Controls\PathInputControl; use Sendama\Console\Editor\Widgets\Controls\PreviewWindowControl; use Sendama\Console\Editor\Widgets\Controls\TextInputControl; use Sendama\Console\Editor\Widgets\Controls\VectorInputControl; @@ -18,6 +19,8 @@ class InspectorPanel extends Widget private const string STATE_CONTROL_SELECTION = 'control_selection'; private const string STATE_PROPERTY_SELECTION = 'property_selection'; 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 SELECTED_CONTROL_SEQUENCE = "\033[30;46m"; @@ -33,10 +36,13 @@ class InspectorPanel extends Widget protected array $lineStates = []; protected string $interactionState = self::STATE_CONTROL_SELECTION; protected InputControlFactory $inputControlFactory; - protected ?TextInputControl $rendererTextureControl = null; + protected ?PathInputControl $rendererTextureControl = null; protected ?VectorInputControl $rendererOffsetControl = null; protected ?VectorInputControl $rendererSizeControl = null; protected ?PreviewWindowControl $rendererPreviewControl = null; + protected OptionListModal $pathInputActionModal; + protected FileDialogModal $fileDialogModal; + protected ?PathInputControl $activePathInputControl = null; public function __construct( array $position = ['x' => 135, 'y' => 1], @@ -46,6 +52,8 @@ public function __construct( { parent::__construct('Inspector', '', $position, $width, $height); $this->inputControlFactory = new InputControlFactory(); + $this->pathInputActionModal = new OptionListModal(title: 'Path Input'); + $this->fileDialogModal = new FileDialogModal(); } public function inspectTarget(?array $target): void @@ -59,6 +67,9 @@ public function inspectTarget(?array $target): void $this->rendererOffsetControl = null; $this->rendererSizeControl = null; $this->rendererPreviewControl = null; + $this->pathInputActionModal->hide(); + $this->fileDialogModal->hide(); + $this->activePathInputControl = null; if ($target === null) { $this->content = []; @@ -104,6 +115,39 @@ public function blur(FocusTargetContext $context): void $this->refreshContent(); } + public function hasActiveModal(): bool + { + return $this->pathInputActionModal->isVisible() || $this->fileDialogModal->isVisible(); + } + + public function isModalDirty(): bool + { + return $this->pathInputActionModal->isDirty() || $this->fileDialogModal->isDirty(); + } + + public function markModalClean(): void + { + $this->pathInputActionModal->markClean(); + $this->fileDialogModal->markClean(); + } + + public function syncModalLayout(int $terminalWidth, int $terminalHeight): void + { + $this->pathInputActionModal->syncLayout($terminalWidth, $terminalHeight); + $this->fileDialogModal->syncLayout($terminalWidth, $terminalHeight); + } + + public function renderActiveModal(): void + { + if ($this->pathInputActionModal->isVisible()) { + $this->pathInputActionModal->render(); + } + + if ($this->fileDialogModal->isVisible()) { + $this->fileDialogModal->render(); + } + } + public function cycleFocusForward(): bool { if ($this->interactionState !== self::STATE_CONTROL_SELECTION || $this->focusableControls === []) { @@ -128,7 +172,21 @@ public function cycleFocusBackward(): bool public function update(): void { - if (!$this->hasFocus() || $this->selectedControlIndex === null) { + if (!$this->hasFocus()) { + return; + } + + if ($this->interactionState === self::STATE_PATH_INPUT_ACTION_SELECTION) { + $this->handlePathInputActionInput(); + return; + } + + if ($this->interactionState === self::STATE_PATH_INPUT_FILE_DIALOG) { + $this->handlePathInputFileDialogInput(); + return; + } + + if ($this->selectedControlIndex === null) { return; } @@ -149,6 +207,11 @@ public function update(): void $this->refreshContent(); } + public function renderAt(?int $x = null, ?int $y = null): void + { + parent::renderAt($x, $y); + } + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string { $contentIndex = $lineIndex - $this->padding->topPadding; @@ -257,7 +320,12 @@ private function addRendererControls(array $item): void $offset = $this->normalizeVector($texture['position'] ?? null); $size = $this->normalizeVector($texture['size'] ?? null); - $this->rendererTextureControl = new TextInputControl('Texture', $texturePath, 1); + $this->rendererTextureControl = new PathInputControl( + 'Texture', + $texturePath, + $this->resolveAssetsWorkingDirectory(), + 1, + ); $this->rendererOffsetControl = new VectorInputControl('Offset', $offset, 1); $this->rendererSizeControl = new VectorInputControl('Size', $size, 1); $this->rendererPreviewControl = new PreviewWindowControl( @@ -358,7 +426,7 @@ private function refreshContent(): void private function refreshDerivedControls(): void { if ( - !$this->rendererTextureControl instanceof TextInputControl + !$this->rendererTextureControl instanceof PathInputControl || !$this->rendererOffsetControl instanceof VectorInputControl || !$this->rendererSizeControl instanceof VectorInputControl || !$this->rendererPreviewControl instanceof PreviewWindowControl @@ -437,6 +505,11 @@ private function handleControlSelectionInput(InputControl $selectedControl): voi return; } + if ($selectedControl instanceof PathInputControl) { + $this->showPathInputActionModal($selectedControl); + return; + } + if ($selectedControl instanceof CompoundInputControl) { if ($selectedControl->beginPropertySelection()) { $this->interactionState = self::STATE_PROPERTY_SELECTION; @@ -525,6 +598,11 @@ private function commitSelectedEdit(InputControl $selectedControl): void } $selectedControl->commitEdit(); + + if ($selectedControl instanceof PathInputControl) { + $this->activePathInputControl = null; + } + $this->interactionState = self::STATE_CONTROL_SELECTION; } @@ -537,11 +615,17 @@ private function cancelSelectedEdit(InputControl $selectedControl): void } $selectedControl->cancelEdit(); + + if ($selectedControl instanceof PathInputControl) { + $this->activePathInputControl = null; + } + $this->interactionState = self::STATE_CONTROL_SELECTION; } private function resetInteractionState(): void { + $this->closePathInputModals(); $selectedControl = $this->getSelectedControl(); if ($selectedControl instanceof CompoundInputControl) { @@ -557,6 +641,126 @@ private function resetInteractionState(): void $this->interactionState = self::STATE_CONTROL_SELECTION; } + private function handlePathInputActionInput(): void + { + if (Input::isKeyDown(KeyCode::ESCAPE)) { + $this->closePathInputModals(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + $this->refreshContent(); + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $this->pathInputActionModal->moveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->pathInputActionModal->moveSelection(1); + return; + } + + if (!Input::isKeyDown(KeyCode::ENTER)) { + return; + } + + $selectedOption = $this->pathInputActionModal->getSelectedOption(); + + if ($selectedOption === 'Choose file') { + $this->pathInputActionModal->hide(); + + if ($this->activePathInputControl instanceof PathInputControl) { + $this->fileDialogModal->show( + $this->activePathInputControl->getWorkingDirectory(), + (string) $this->activePathInputControl->getValue(), + ); + $this->interactionState = self::STATE_PATH_INPUT_FILE_DIALOG; + } + + return; + } + + if ($selectedOption === 'Edit path' && $this->activePathInputControl instanceof PathInputControl) { + $this->pathInputActionModal->hide(); + + if ($this->activePathInputControl->enterEditMode()) { + $this->interactionState = self::STATE_CONTROL_EDIT; + } else { + $this->closePathInputModals(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + } + } + } + + private function handlePathInputFileDialogInput(): void + { + if (Input::isKeyDown(KeyCode::ESCAPE)) { + $this->fileDialogModal->hide(); + + if ($this->activePathInputControl instanceof PathInputControl) { + $this->pathInputActionModal->show(['Choose file', 'Edit path'], 0); + $this->interactionState = self::STATE_PATH_INPUT_ACTION_SELECTION; + } else { + $this->interactionState = self::STATE_CONTROL_SELECTION; + } + + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $this->fileDialogModal->moveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->fileDialogModal->moveSelection(1); + return; + } + + if (Input::isKeyDown(KeyCode::RIGHT)) { + $this->fileDialogModal->expandSelection(); + return; + } + + if (Input::isKeyDown(KeyCode::LEFT)) { + $this->fileDialogModal->collapseSelection(); + return; + } + + if (!Input::isKeyDown(KeyCode::ENTER)) { + return; + } + + $selectedPath = $this->fileDialogModal->submitSelection(); + + if ($selectedPath === null || !$this->activePathInputControl instanceof PathInputControl) { + return; + } + + $this->activePathInputControl->setValueFromRelativePath($selectedPath); + $this->closePathInputModals(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + $this->refreshContent(); + } + + private function showPathInputActionModal(PathInputControl $control): void + { + $this->activePathInputControl = $control; + $this->pathInputActionModal->show(['Choose file', 'Edit path']); + $this->interactionState = self::STATE_PATH_INPUT_ACTION_SELECTION; + $terminalSize = get_max_terminal_size(); + $terminalWidth = $terminalSize['width'] ?? DEFAULT_TERMINAL_WIDTH; + $terminalHeight = $terminalSize['height'] ?? DEFAULT_TERMINAL_HEIGHT; + $this->syncModalLayout($terminalWidth, $terminalHeight); + } + + private function closePathInputModals(): void + { + $this->pathInputActionModal->hide(); + $this->fileDialogModal->hide(); + $this->activePathInputControl = null; + } + private function buildTexturePreviewLines(string $texturePath, array $offset, array $size): array { if ($texturePath === 'None') { @@ -732,4 +936,23 @@ private function humanizeKey(string $key): string return ucwords(trim($spacedKey)); } + + private function resolveAssetsWorkingDirectory(): string + { + $workingDirectory = getcwd() ?: '.'; + $assetRoots = [ + $workingDirectory . '/Assets', + $workingDirectory . '/assets', + $workingDirectory, + ]; + + foreach ($assetRoots as $assetRoot) { + if (is_dir($assetRoot)) { + return $assetRoot; + } + } + + return $workingDirectory; + } + } diff --git a/src/Editor/Widgets/MainPanel.php b/src/Editor/Widgets/MainPanel.php index eb88d0f..75fc62b 100644 --- a/src/Editor/Widgets/MainPanel.php +++ b/src/Editor/Widgets/MainPanel.php @@ -3,19 +3,24 @@ namespace Sendama\Console\Editor\Widgets; use Atatusoft\Termutil\IO\Enumerations\Color; -use Sendama\Console\Editor\IO\Enumerations\KeyCode; -use Sendama\Console\Editor\IO\Input; class MainPanel extends Widget { private const string DIVIDER_LINE_CHARACTER = '─'; private const string TAB_DIVIDER_LINE_CHARACTER = '■'; private const array TAB_TITLES = ['Scene', 'Game', 'Sprite']; + private const string GAME_IDLE_PATTERN_CHARACTER = '/'; + private const string GAME_IDLE_PROMPT = 'Shift+5 to Play'; + private const Color DEFAULT_FOCUS_COLOR = Color::LIGHT_CYAN; + private const Color PLAY_MODE_FOCUS_COLOR = Color::BROWN; protected int $activeTabIndex = 0; protected int $activeTabOffset = 0; protected int $activeTabLength = 0; protected Color $activeIndicatorColor = Color::LIGHT_CYAN; + protected bool $isPlayModeActive = false; + protected array $gameIdleContentIndexes = []; + protected ?int $gameIdlePromptContentIndex = null; public function __construct( array $position = ['x' => 37, 'y' => 1], @@ -24,6 +29,7 @@ public function __construct( ) { parent::__construct('', '', $position, $width, $height); + $this->focusBorderColor = self::DEFAULT_FOCUS_COLOR; $this->refreshContent(); } @@ -57,20 +63,35 @@ public function selectTab(string $tabTitle): void $this->refreshContent(); } - public function update(): void + public function cycleFocusForward(): bool { - if ($this->hasFocus()) { - if (Input::isKeyDown(KeyCode::RIGHT)) { - $this->activateNextTab(); - return; - } + $this->activateNextTab(); - if (Input::isKeyDown(KeyCode::LEFT)) { - $this->activatePreviousTab(); - return; - } + return true; + } + + public function cycleFocusBackward(): bool + { + $this->activatePreviousTab(); + + return true; + } + + public function setPlayModeActive(bool $isPlayModeActive): void + { + if ($this->isPlayModeActive === $isPlayModeActive) { + return; } + $this->isPlayModeActive = $isPlayModeActive; + $this->focusBorderColor = $isPlayModeActive + ? self::PLAY_MODE_FOCUS_COLOR + : self::DEFAULT_FOCUS_COLOR; + $this->refreshContent(); + } + + public function update(): void + { $this->refreshContent(); } @@ -102,8 +123,14 @@ public function handleMouseClick(int $x, int $y): void protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string { + $contentIndex = $lineIndex - $this->padding->topPadding; + if ($lineIndex !== 1) { - return parent::decorateContentLine($line, $contentColor, $lineIndex); + if (!in_array($contentIndex, $this->gameIdleContentIndexes, true)) { + return parent::decorateContentLine($line, $contentColor, $lineIndex); + } + + return $this->decorateGameIdleLine($line, $contentColor, $contentIndex); } $visibleLine = mb_substr($line, 0, $this->width); @@ -130,10 +157,60 @@ protected function decorateContentLine(string $line, ?Color $contentColor, int $ . $this->wrapWithColor($rightBorder, $borderColor); } + private function decorateGameIdleLine(string $line, ?Color $contentColor, int $contentIndex): string + { + $visibleLine = mb_substr($line, 0, $this->width); + $visibleLength = mb_strlen($visibleLine); + + if ($visibleLength <= 1) { + return parent::decorateContentLine($line, $contentColor, $contentIndex); + } + + $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; + $decoratedMiddle = $this->colorizeGameIdleMiddle($middle, $contentIndex === $this->gameIdlePromptContentIndex); + + return $this->wrapWithColor($leftBorder, $borderColor) + . $decoratedMiddle + . $this->wrapWithColor($rightBorder, $borderColor); + } + + private function colorizeGameIdleMiddle(string $middle, bool $isPromptLine): string + { + $output = ''; + $promptLength = mb_strlen(self::GAME_IDLE_PROMPT); + $promptStart = $isPromptLine + ? max(0, intdiv(mb_strlen($middle) - $promptLength, 2)) + : -1; + $promptEnd = $promptStart >= 0 ? $promptStart + $promptLength : -1; + + for ($index = 0; $index < mb_strlen($middle); $index++) { + $character = mb_substr($middle, $index, 1); + + if ($isPromptLine && $index >= $promptStart && $index < $promptEnd) { + $output .= $this->wrapWithColor($character, Color::LIGHT_GRAY); + continue; + } + + if ($character === self::GAME_IDLE_PATTERN_CHARACTER) { + $output .= $this->wrapWithColor($character, Color::BLUE); + continue; + } + + $output .= $character; + } + + return $output; + } + private function refreshContent(): void { $tabsLine = ''; $this->activeTabOffset = 0; + $this->gameIdleContentIndexes = []; + $this->gameIdlePromptContentIndex = null; foreach (self::TAB_TITLES as $index => $tabTitle) { if ($index > 0) { @@ -151,8 +228,68 @@ private function refreshContent(): void $activeTabTitle = self::TAB_TITLES[$this->activeTabIndex]; $this->activeTabLength = mb_strlen($activeTabTitle); $dividerLine = $this->buildDividerLine($dividerWidth); + $content = [$tabsLine, $dividerLine]; + + if ($this->shouldRenderIdleGameView()) { + $contentWidth = max(0, $this->innerWidth - $this->padding->leftPadding - $this->padding->rightPadding); + $idleRows = max(0, $this->innerHeight - count($content)); + $promptRow = $idleRows > 0 ? intdiv($idleRows, 2) : null; + + for ($row = 0; $row < $idleRows; $row++) { + $content[] = $this->buildGameIdleLine( + $contentWidth, + $row, + $promptRow !== null && $row === $promptRow, + ); + $contentIndex = count($content) - 1; + $this->gameIdleContentIndexes[] = $contentIndex; + + if ($promptRow !== null && $row === $promptRow) { + $this->gameIdlePromptContentIndex = $contentIndex; + } + } + } - $this->content = [$tabsLine, $dividerLine]; + $this->content = $content; + } + + private function shouldRenderIdleGameView(): bool + { + return $this->getActiveTab() === 'Game' && !$this->isPlayModeActive; + } + + private function buildGameIdleLine(int $width, int $row, bool $includePrompt): string + { + if ($width <= 0) { + return ''; + } + + $characters = array_fill(0, $width, ' '); + + for ($column = 0; $column < $width; $column++) { + if ((($column + ($row * 2)) % 3) === 0) { + $characters[$column] = self::GAME_IDLE_PATTERN_CHARACTER; + } + } + + if (!$includePrompt) { + return implode('', $characters); + } + + $promptLength = mb_strlen(self::GAME_IDLE_PROMPT); + $promptStart = max(0, intdiv($width - $promptLength, 2)); + $clearStart = max(0, $promptStart - 2); + $clearEnd = min($width, $promptStart + $promptLength + 2); + + for ($index = $clearStart; $index < $clearEnd; $index++) { + $characters[$index] = ' '; + } + + for ($index = 0; $index < $promptLength && ($promptStart + $index) < $width; $index++) { + $characters[$promptStart + $index] = mb_substr(self::GAME_IDLE_PROMPT, $index, 1); + } + + return implode('', $characters); } private function buildDividerLine(int $dividerWidth): string diff --git a/src/Editor/Widgets/OptionListModal.php b/src/Editor/Widgets/OptionListModal.php new file mode 100644 index 0000000..1aaccf7 --- /dev/null +++ b/src/Editor/Widgets/OptionListModal.php @@ -0,0 +1,161 @@ + 1, 'y' => 1], + width: 28, + height: 7, + ); + } + + public function show(array $options, int $selectedIndex = 0): void + { + $this->options = array_values($options); + $optionCount = count($this->options); + $this->selectedIndex = $optionCount > 0 + ? max(0, min($selectedIndex, $optionCount - 1)) + : 0; + $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 moveSelection(int $offset): void + { + $optionCount = count($this->options); + + if ($optionCount === 0) { + return; + } + + $this->selectedIndex = ($this->selectedIndex + $offset + $optionCount) % $optionCount; + $this->refreshContent(); + $this->markDirty(); + } + + public function getSelectedOption(): ?string + { + return $this->options[$this->selectedIndex] ?? null; + } + + public function syncLayout(int $terminalWidth, int $terminalHeight): void + { + $longestOptionLength = 0; + + foreach ($this->options as $option) { + $longestOptionLength = max($longestOptionLength, mb_strlen($option)); + } + + $desiredWidth = max( + 24, + $longestOptionLength + 6, + mb_strlen($this->title) + 4, + mb_strlen($this->help) + 4, + ); + $modalWidth = min( + $desiredWidth, + max(3, $terminalWidth - 2) + ); + $modalHeight = min(max(5, count($this->options) + 2), 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 + { + } + + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string + { + $selectedLineIndex = $this->padding->topPadding + $this->selectedIndex; + + if ($lineIndex !== $selectedLineIndex) { + return parent::decorateContentLine($line, $contentColor, $lineIndex); + } + + $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); + + return $this->wrapWithColor($leftBorder, $contentColor) + . $this->wrapWithSequence($middle, self::SELECTED_ROW_SEQUENCE) + . $this->wrapWithColor($rightBorder, $contentColor); + } + + private function refreshContent(): void + { + $this->content = array_map( + fn(string $option, int $index) => (($index === $this->selectedIndex) ? '>' : ' ') . ' ' . $option, + $this->options, + array_keys($this->options), + ); + } + + private function markDirty(): void + { + $this->isDirty = true; + } +} diff --git a/src/Editor/Widgets/Widget.php b/src/Editor/Widgets/Widget.php index ffbdaed..b05dc8b 100644 --- a/src/Editor/Widgets/Widget.php +++ b/src/Editor/Widgets/Widget.php @@ -3,6 +3,7 @@ namespace Sendama\Console\Editor\Widgets; use Atatusoft\Termutil\IO\Enumerations\Color; +use Atatusoft\Termutil\UI\Windows\Enumerations\HorizontalAlignment; use Atatusoft\Termutil\UI\Windows\Window; use Sendama\Console\Editor\FocusTargetContext; use Sendama\Console\Editor\Interfaces\FocusableInterface; @@ -116,6 +117,28 @@ public function cycleFocusBackward(): bool return false; } + public function hasActiveModal(): bool + { + return false; + } + + public function isModalDirty(): bool + { + return false; + } + + public function markModalClean(): void + { + } + + public function syncModalLayout(int $terminalWidth, int $terminalHeight): void + { + } + + public function renderActiveModal(): void + { + } + public function setTopSibling(?Widget $widget): void { $this->topSibling = $widget; @@ -217,10 +240,10 @@ public function renderAt(?int $x = null, ?int $y = null): void $contentColor = $this->foregroundColor; $this->foregroundColor = null; - $topBorder = $this->topBorder; - $linesOfContent = $this->linesOfContent; - $bottomBorder = $this->bottomBorder; + $linesOfContent = $this->buildRenderedContentLines(); $this->foregroundColor = $contentColor; + $topBorder = $this->buildBorderLine($this->title, true); + $bottomBorder = $this->buildBorderLine($this->help, false); if (!$linesOfContent) { $linesOfContent = ['']; @@ -269,6 +292,165 @@ protected function decorateContentLine(string $line, ?Color $contentColor, int $ . $this->wrapWithColor($rightBorder, $this->focusBorderColor); } + protected function buildRenderedContentLines(): array + { + $innerWidth = max(1, $this->innerWidth); + $innerHeight = max(1, $this->innerHeight); + $blankLine = $this->borderPack->vertical . str_repeat(' ', $innerWidth) . $this->borderPack->vertical; + $lines = []; + + for ($row = 0; $row < $this->padding->topPadding && count($lines) < $innerHeight; $row++) { + $lines[] = $blankLine; + } + + $contentLineLimit = max(0, $innerHeight - $this->padding->bottomPadding); + + foreach ($this->content as $lineOfContent) { + if (count($lines) >= $contentLineLimit) { + break; + } + + $lines[] = $this->buildContentLine((string) $lineOfContent, $innerWidth); + } + + while (count($lines) < $contentLineLimit) { + $lines[] = $blankLine; + } + + while (count($lines) < $innerHeight) { + $lines[] = $blankLine; + } + + return $lines; + } + + protected function buildContentLine(string $content, int $innerWidth): string + { + $leftPadding = max(0, $this->padding->leftPadding); + $rightPadding = max(0, $this->padding->rightPadding); + $availableTextWidth = max(0, $innerWidth - $leftPadding - $rightPadding); + $visibleContent = $this->clipContentToWidth($content, $availableTextWidth); + $visibleLength = mb_strlen($visibleContent); + + $contentArea = match ($this->alignment->horizontalAlignment) { + HorizontalAlignment::CENTER => $this->buildCenteredContentArea( + $visibleContent, + $visibleLength, + $innerWidth, + $leftPadding, + $rightPadding, + ), + HorizontalAlignment::RIGHT => $this->buildRightAlignedContentArea( + $visibleContent, + $visibleLength, + $innerWidth, + $rightPadding, + ), + default => $this->buildLeftAlignedContentArea( + $visibleContent, + $visibleLength, + $innerWidth, + $leftPadding, + ), + }; + + return $this->borderPack->vertical . $contentArea . $this->borderPack->vertical; + } + + protected function buildLeftAlignedContentArea( + string $visibleContent, + int $visibleLength, + int $innerWidth, + int $leftPadding, + ): string + { + $contentArea = str_repeat(' ', min($leftPadding, $innerWidth)) . $visibleContent; + + return $this->padContentArea($contentArea, $innerWidth, STR_PAD_RIGHT); + } + + protected function buildCenteredContentArea( + string $visibleContent, + int $visibleLength, + int $innerWidth, + int $leftPadding, + int $rightPadding, + ): string + { + $availableWidth = max(0, $innerWidth - $leftPadding - $rightPadding); + $remainingWidth = max(0, $availableWidth - $visibleLength); + $leftExtraPadding = intdiv($remainingWidth, 2); + $rightExtraPadding = $remainingWidth - $leftExtraPadding; + $contentArea = str_repeat(' ', min($leftPadding + $leftExtraPadding, $innerWidth)) + . $visibleContent + . str_repeat(' ', $rightPadding + $rightExtraPadding); + + return $this->padContentArea($contentArea, $innerWidth, STR_PAD_BOTH); + } + + protected function buildRightAlignedContentArea( + string $visibleContent, + int $visibleLength, + int $innerWidth, + int $rightPadding, + ): string + { + $leftSpace = max(0, $innerWidth - $rightPadding - $visibleLength); + $contentArea = str_repeat(' ', $leftSpace) . $visibleContent; + + return $this->padContentArea($contentArea, $innerWidth, STR_PAD_RIGHT); + } + + protected function padContentArea(string $contentArea, int $innerWidth, int $direction): string + { + $visibleArea = $this->clipContentToWidth($contentArea, $innerWidth); + $visibleLength = mb_strlen($visibleArea); + + if ($visibleLength >= $innerWidth) { + return $visibleArea; + } + + $paddingWidth = $innerWidth - $visibleLength; + + return match ($direction) { + STR_PAD_LEFT => str_repeat(' ', $paddingWidth) . $visibleArea, + STR_PAD_BOTH => str_repeat(' ', intdiv($paddingWidth, 2)) + . $visibleArea + . str_repeat(' ', $paddingWidth - intdiv($paddingWidth, 2)), + default => $visibleArea . str_repeat(' ', $paddingWidth), + }; + } + + protected function clipContentToWidth(string $content, int $maxWidth): string + { + if ($maxWidth <= 0) { + return ''; + } + + if (mb_strlen($content) <= $maxWidth) { + return $content; + } + + return mb_substr($content, 0, $maxWidth); + } + + protected function buildBorderLine(string $label, bool $isTopBorder): string + { + $availableLabelWidth = max(0, $this->width - 3); + $visibleLabel = mb_substr($label, 0, $availableLabelWidth); + $labelWidth = mb_strlen($visibleLabel); + $remainderWidth = max(0, $this->width - $labelWidth - 3); + + $leftCorner = $isTopBorder ? $this->borderPack->topLeft : $this->borderPack->bottomLeft; + $rightCorner = $isTopBorder ? $this->borderPack->topRight : $this->borderPack->bottomRight; + + return $leftCorner + . $this->borderPack->horizontal + . $visibleLabel + . str_repeat($this->borderPack->horizontal, $remainderWidth) + . $rightCorner; + } + protected function wrapWithColor(string $content, ?Color $color): string { return $this->wrapWithSequence($content, $color?->value); diff --git a/tests/Unit/FileDialogModalTest.php b/tests/Unit/FileDialogModalTest.php new file mode 100644 index 0000000..fba5c53 --- /dev/null +++ b/tests/Unit/FileDialogModalTest.php @@ -0,0 +1,65 @@ +show($workspace . '/Assets'); + + expect($modal->content[0])->toBe('► Textures'); + + $modal->expandSelection(); + $modal->moveSelection(1); + + expect($modal->content[1])->toBe(' • player.texture'); + expect($modal->submitSelection())->toBe('Textures/player.texture'); +}); + +test('file dialog modal can preselect the current relative path', function () { + $workspace = sys_get_temp_dir() . '/sendama-file-dialog-' . uniqid(); + mkdir($workspace . '/Assets/Textures', 0777, true); + file_put_contents($workspace . '/Assets/Textures/player.texture', 'texture'); + + $modal = new FileDialogModal(); + $modal->show($workspace . '/Assets', 'Textures/player.texture'); + + expect($modal->content[0])->toBe('▼ Textures'); + expect($modal->content[1])->toBe(' • player.texture'); + expect($modal->submitSelection())->toBe('Textures/player.texture'); +}); + +test('file dialog modal does not expand directories when submitting', function () { + $workspace = sys_get_temp_dir() . '/sendama-file-dialog-' . uniqid(); + mkdir($workspace . '/Assets/Textures', 0777, true); + file_put_contents($workspace . '/Assets/Textures/player.texture', 'texture'); + + $modal = new FileDialogModal(); + $modal->show($workspace . '/Assets'); + + expect($modal->content)->toBe(['► Textures']); + expect($modal->submitSelection())->toBeNull(); + expect($modal->content)->toBe(['► Textures']); +}); + +test('file dialog modal tracks dirty state across changes', function () { + $workspace = sys_get_temp_dir() . '/sendama-file-dialog-' . uniqid(); + mkdir($workspace . '/Assets/Textures', 0777, true); + file_put_contents($workspace . '/Assets/Textures/player.texture', 'texture'); + + $modal = new FileDialogModal(); + $modal->show($workspace . '/Assets'); + + expect($modal->isDirty())->toBeTrue(); + + $modal->markClean(); + + expect($modal->isDirty())->toBeFalse(); + + $modal->expandSelection(); + + expect($modal->isDirty())->toBeTrue(); +}); diff --git a/tests/Unit/InputManagerTest.php b/tests/Unit/InputManagerTest.php index 07a570f..e448f41 100644 --- a/tests/Unit/InputManagerTest.php +++ b/tests/Unit/InputManagerTest.php @@ -42,3 +42,10 @@ expect($getKey->invoke(null, "\033[1;2D"))->toBe(KeyCode::SHIFT_LEFT->value); expect($getKey->invoke(null, "\033[d"))->toBe(KeyCode::SHIFT_LEFT->value); }); + +test('input manager preserves the shift+5 play toggle input', function () { + $getKey = new ReflectionMethod(InputManager::class, 'getKey'); + $getKey->setAccessible(true); + + expect($getKey->invoke(null, '%'))->toBe(KeyCode::PLAY_TOGGLE->value); +}); diff --git a/tests/Unit/InspectorPanelTest.php b/tests/Unit/InspectorPanelTest.php index 0ad3940..940b093 100644 --- a/tests/Unit/InspectorPanelTest.php +++ b/tests/Unit/InspectorPanelTest.php @@ -164,3 +164,64 @@ 'Name: Textures', ]); }); + +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); + file_put_contents($workspace . '/Assets/Textures/player.texture', "x\n"); + $originalWorkingDirectory = getcwd(); + $panel = new InspectorPanel(width: 48, height: 24); + + chdir($workspace); + + try { + $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], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ]); + + $hasFocus = new ReflectionProperty(\Sendama\Console\Editor\Widgets\Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + for ($index = 0; $index < 6; $index++) { + $panel->cycleFocusForward(); + } + + $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("\n"); + + $panel->update(); + + expect($panel->hasActiveModal())->toBeTrue(); + expect($panel->isModalDirty())->toBeTrue(); + + $panel->markModalClean(); + + expect($panel->isModalDirty())->toBeFalse(); + } finally { + if ($originalWorkingDirectory !== false) { + chdir($originalWorkingDirectory); + } + } +}); diff --git a/tests/Unit/MainPanelTest.php b/tests/Unit/MainPanelTest.php index 5867526..b8e1ca1 100644 --- a/tests/Unit/MainPanelTest.php +++ b/tests/Unit/MainPanelTest.php @@ -1,6 +1,8 @@ getActiveTab())->toBe('Sprite'); }); +test('main panel uses focus cycling to move between tabs', function () { + $panel = new MainPanel(width: 60, height: 12); + + expect($panel->cycleFocusForward())->toBeTrue(); + expect($panel->getActiveTab())->toBe('Game'); + + expect($panel->cycleFocusBackward())->toBeTrue(); + expect($panel->getActiveTab())->toBe('Scene'); +}); + +test('main panel shows a play prompt on the game tab while not in play mode', function () { + $panel = new MainPanel(width: 60, height: 12); + + $panel->selectTab('Game'); + + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'Shift+5 to Play')))->toBeTrue(); +}); + +test('main panel hides the play prompt while play mode is active', function () { + $panel = new MainPanel(width: 60, height: 12); + + $panel->selectTab('Game'); + $panel->setPlayModeActive(true); + + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'Shift+5 to Play')))->toBeFalse(); + expect($panel->content)->toHaveCount(2); +}); + +test('main panel uses a warm focus color while in play mode', function () { + $panel = new MainPanel(width: 60, height: 12); + $focusBorderColor = new ReflectionProperty(Widget::class, 'focusBorderColor'); + $focusBorderColor->setAccessible(true); + + expect($focusBorderColor->getValue($panel))->toBe(Color::LIGHT_CYAN); + + $panel->setPlayModeActive(true); + + expect($focusBorderColor->getValue($panel))->toBe(Color::BROWN); +}); + test('main panel highlights the active tab in the divider', function () { $panel = new MainPanel(width: 60, height: 12); diff --git a/tests/Unit/PathInputControlTest.php b/tests/Unit/PathInputControlTest.php new file mode 100644 index 0000000..973f978 --- /dev/null +++ b/tests/Unit/PathInputControlTest.php @@ -0,0 +1,12 @@ +setValueFromRelativePath('Textures\\enemy.texture'); + + expect($control->getWorkingDirectory())->toBe('/tmp/project/Assets'); + expect($control->getValue())->toBe('Textures/enemy.texture'); +}); diff --git a/tests/Unit/WidgetTest.php b/tests/Unit/WidgetTest.php index 648c154..14fde7f 100644 --- a/tests/Unit/WidgetTest.php +++ b/tests/Unit/WidgetTest.php @@ -46,3 +46,54 @@ public function update(): void expect($hierarchy->getSibling('bottom'))->toBe($assets); expect($hierarchy->getSibling('left'))->toBeNull(); }); + +test('widget safely renders long border labels in narrow windows', function () { + $widget = new class extends Widget { + public function __construct() + { + parent::__construct( + 'Very Long Title', + 'This help text is far too long for the window', + ['x' => 1, 'y' => 1], + 12, + 5, + ); + $this->content = ['content']; + } + + public function update(): void + { + } + }; + + ob_start(); + $widget->renderAt(); + $output = ob_get_clean(); + + expect($output)->not->toBeFalse(); + expect($output)->toBeString(); + expect($output)->not->toBe(''); +}); + +test('widget clips long content lines to the available window width', function () { + $widget = new class extends Widget { + public function __construct() + { + parent::__construct('Inspector', '', ['x' => 1, 'y' => 1], 20, 6); + $this->content = ['Texture: Textures/bullet.texture']; + } + + public function update(): void + { + } + }; + + $buildRenderedContentLines = new ReflectionMethod($widget, 'buildRenderedContentLines'); + $buildRenderedContentLines->setAccessible(true); + $lines = $buildRenderedContentLines->invoke($widget); + + expect($lines)->toHaveCount(4); + expect($lines[0])->toBe('│ Texture: Texture │'); + expect(mb_strlen($lines[0]))->toBe(20); + expect(mb_substr($lines[0], -1))->toBe('│'); +});