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..7a1e10c --- /dev/null +++ b/src/Editor/DTOs/SceneDTO.php @@ -0,0 +1,42 @@ + $this->name, + "width" => $this->width, + "height" => $this->height, + "environmentTileMapPath" => $this->environmentTileMapPath, + "isDirty" => $this->isDirty, + "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->isDirty = $data['isDirty'] ?? false; + $this->hierarchy = $data['hierarchy'] ?? []; + } +} diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php index 9645ae6..24f8c95 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,11 +21,15 @@ 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; @@ -114,7 +119,17 @@ 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; + protected bool $didRenderOverlayLastFrame = false; /** * @param string $name @@ -133,6 +148,8 @@ public function __construct( $this->initializeObservers(); $this->configureErrorAndExceptionHandlers(); $this->initializeSettings(); + $this->initializeLoadedScene(); + $this->refreshTerminalSize(force: true); $this->initializeManagers(); $this->initializeConsole(); $this->initializeWidgets(); @@ -194,10 +211,12 @@ 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(); + Console::enableMouseReporting(); + InputManager::disableEcho(); InputManager::enableNonBlockingMode(); @@ -227,14 +246,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"); } @@ -295,13 +316,22 @@ 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, ] ); $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); + } } /** @@ -312,6 +342,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)); } @@ -323,25 +354,84 @@ private function handleInput(): void */ private function update(): void { + if ($this->frameCount % 10 === 0) { + $this->refreshTerminalSize(); + } + $this->editorState->update(); + $this->handlePanelKeyboardWorkflow(); + + if ($this->panelListModal->isVisible()) { + $this->notify(new EditorEvent(EventType::EDITOR_UPDATED->value, $this)); + return; + } + + $this->syncPlayModeState(); foreach ($this->panels as $panel) { $panel->update(); } + $this->synchronizeInspectorPanel(); + $this->notify(new EditorEvent(EventType::EDITOR_UPDATED->value, $this)); } private function render(): void { $this->frameCount++; + if ($this->panelListModal->isVisible()) { + $this->didRenderOverlayLastFrame = true; + + 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; + } + + 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(); + + $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 @@ -412,8 +502,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, ]); } @@ -427,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 @@ -437,6 +551,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 */ @@ -451,15 +572,328 @@ 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( + sceneName: $this->loadedScene?->name ?? 'Scene', + isSceneDirty: $this->loadedScene?->isDirty ?? false, + hierarchy: $this->loadedScene?->hierarchy ?? [], + ); + $this->assetsPanel = new AssetsPanel( + assetsDirectoryPath: $this->assetsDirectoryPath, + ); + $this->mainPanel = new MainPanel(); + $this->consolePanel = new ConsolePanel( + logFilePath: Path::join($this->workingDirectory, 'logs', 'debug.log'), + ); + $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->configurePanelGraph(); + $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 (Input::isKeyDown(IO\Enumerations\KeyCode::PLAY_TOGGLE, false)) { + $this->togglePlayMode(); + return; + } + + if ($this->panelListModal->isVisible()) { + $this->handlePanelListModalInput(); + return; + } + + if (Input::getCurrentInput() === '!') { + $this->showPanelListModal(); + return; + } + + if ($this->focusedPanel?->hasActiveModal()) { + return; + } + + 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->focusedPanel?->cycleFocusForward(); + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::SHIFT_TAB)) { + $this->focusedPanel?->cycleFocusBackward(); + } + } + + 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->focusedPanel?->hasActiveModal()) { + $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); + $this->focusedPanel?->syncModalLayout($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(); + $panelIndex = $this->getFocusedPanelIndex(); + $nextIndex = ($panelIndex + 1) % count($panels); + + /** @var Widget $panel */ + $panel = $panels[$nextIndex]; + $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) { + 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 synchronizeInspectorPanel(): void + { + $selectedItem = $this->hierarchyPanel->consumeInspectionRequest() + ?? $this->assetsPanel->consumeInspectionRequest(); + + if ($selectedItem === null) { + return; + } + + $this->inspectorPanel->inspectTarget($selectedItem); + } + + 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/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/IO/Enumerations/KeyCode.php b/src/Editor/IO/Enumerations/KeyCode.php index 8f2e512..4f9257f 100644 --- a/src/Editor/IO/Enumerations/KeyCode.php +++ b/src/Editor/IO/Enumerations/KeyCode.php @@ -12,6 +12,11 @@ enum KeyCode: string case ENTER = 'enter'; 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'; @@ -51,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/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..01fa4c1 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. * @@ -108,8 +132,13 @@ 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, "\010", "\177" => KeyCode::BACKSPACE->value, @@ -290,4 +319,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..a917642 --- /dev/null +++ b/src/Editor/SceneLoader.php @@ -0,0 +1,208 @@ +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', + isDirty: $sceneData['isDirty'] ?? false, + 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); + 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' => $hierarchy, + ]; + } + + 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 c833b54..fe25d6d 100644 --- a/src/Editor/Widgets/AssetsPanel.php +++ b/src/Editor/Widgets/AssetsPanel.php @@ -2,22 +2,406 @@ 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 assets in the current project's Assets directory. + */ class AssetsPanel extends Widget { + private const string COLLAPSED_ICON = '►'; + private const string EXPANDED_ICON = '▼'; + private const string LEAF_ICON = '•'; + private const string SELECTED_ROW_SEQUENCE = "\033[30;46m"; + private const string SELECTED_ROW_FOCUSED_SEQUENCE = "\033[5;30;46m"; + + 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, - int $height = 14 + int $height = 14, + protected ?string $assetsDirectoryPath = null ) { parent::__construct('Assets', '', $position, $width, $height); + $this->loadAssetEntries(); + $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)) { + return; + } + + $index = $y - $this->getContentAreaTop(); + + if (!isset($this->visibleAssets[$index])) { + return; + } + + $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 + { + if (!$this->assetsDirectoryPath) { + $this->assetsDirectoryPath = Path::getWorkingDirectoryAssetsPath(); + } + + 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->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->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 substr($path, 0, $separatorPosition); } -} \ 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..4569bfa --- /dev/null +++ b/src/Editor/Widgets/ConsolePanel.php @@ -0,0 +1,175 @@ + 37, 'y' => 22], + int $width = 96, + 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->scrollToRecentLines(); + $this->refreshVisibleContent(); + } + + public function clear(): void + { + $this->messages = []; + $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 + { + 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/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/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/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/HierarchyPanel.php b/src/Editor/Widgets/HierarchyPanel.php index e86e568..308daf6 100644 --- a/src/Editor/Widgets/HierarchyPanel.php +++ b/src/Editor/Widgets/HierarchyPanel.php @@ -2,18 +2,196 @@ namespace Sendama\Console\Editor\Widgets; +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; -use Sendama\Console\Debug\Debug; - -class HierarchyPanel extends Widget +/** + * HierarchyPanel class. + * + * @package + */ +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 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 + 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); + } + + public function getHierarchy(): array + { + return $this->hierarchy; + } + + public function setHierarchy(array $hierarchy): void + { + $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 + { + $selectedNode = $this->getSelectedVisibleNode(); + + if (($selectedNode['kind'] ?? null) !== 'object') { + return 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 + { + if (!$this->containsPoint($x, $y)) { + return; + } + + $index = $y - $this->getContentAreaTop(); + + if (!isset($this->visibleHierarchy[$index])) { + return; + } + + $this->selectedPath = $this->visibleHierarchy[$index]['path'] ?? $this->selectedPath; + $this->refreshContent(); } /** @@ -21,6 +199,239 @@ public function __construct( */ 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->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); } -} \ No newline at end of file +} diff --git a/src/Editor/Widgets/InspectorPanel.php b/src/Editor/Widgets/InspectorPanel.php index 81923e4..ba603a0 100644 --- a/src/Editor/Widgets/InspectorPanel.php +++ b/src/Editor/Widgets/InspectorPanel.php @@ -2,10 +2,48 @@ 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\PathInputControl; +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 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"; + 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 ?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], int $width = 35, @@ -13,13 +51,908 @@ 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 + { + $this->inspectionTarget = $target; + $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; + $this->pathInputActionModal->hide(); + $this->fileDialogModal->hide(); + $this->activePathInputControl = null; + + if ($target === null) { + $this->content = []; + $this->lineKinds = []; + $this->lineStates = []; + return; + } + + $context = $target['context'] ?? null; + $value = $target['value'] ?? null; + + if ($context === 'hierarchy' && is_array($value)) { + $this->buildHierarchyControls($target, $value); + } else { + $this->buildGenericControls($target); + } + + 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 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 === []) { + 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()) { + 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; + } + + $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(); + } + + 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; + $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 PathInputControl( + 'Texture', + $texturePath, + $this->resolveAssetsWorkingDirectory(), + 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 PathInputControl + || !$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(); + } + } } -} \ No newline at end of file + + 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 PathInputControl) { + $this->showPathInputActionModal($selectedControl); + 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(); + + if ($selectedControl instanceof PathInputControl) { + $this->activePathInputControl = null; + } + + $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(); + + 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) { + if ($this->interactionState === self::STATE_CONTROL_EDIT) { + $selectedControl->cancelActiveEdit(); + } + + $selectedControl->endPropertySelection(); + } elseif ($selectedControl?->isEditing()) { + $selectedControl->cancelEdit(); + } + + $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') { + 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], + int $width = 96, + int $height = 21 + ) + { + parent::__construct('', '', $position, $width, $height); + $this->focusBorderColor = self::DEFAULT_FOCUS_COLOR; + + $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 cycleFocusForward(): bool + { + $this->activateNextTab(); + + 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(); + } + + 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 + mb_strlen($tabTitle) - 1; + + if ($x >= $tabStart && $x <= $tabEnd) { + $this->activeTabIndex = $index; + $this->refreshContent(); + return; + } + + $currentX = $tabEnd + 1; + } + } + + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string + { + $contentIndex = $lineIndex - $this->padding->topPadding; + + if ($lineIndex !== 1) { + 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); + $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 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) { + $tabsLine .= ' '; + } + + if ($index === $this->activeTabIndex) { + $this->activeTabOffset = mb_strlen($tabsLine); + } + + $tabsLine .= $tabTitle; + } + + $dividerWidth = max(0, $this->innerWidth - 2); + $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 = $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 + { + if ($dividerWidth <= 0) { + return ''; + } + + $characters = array_fill(0, $dividerWidth, self::DIVIDER_LINE_CHARACTER); + + for ($index = 0; $index < $this->activeTabLength; $index++) { + $characterIndex = $this->activeTabOffset + $index; + + if (!isset($characters[$characterIndex])) { + break; + } + + $characters[$characterIndex] = self::TAB_DIVIDER_LINE_CHARACTER; + } + + return implode('', $characters); + } +} 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/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..b05dc8b 100644 --- a/src/Editor/Widgets/Widget.php +++ b/src/Editor/Widgets/Widget.php @@ -2,6 +2,8 @@ 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; @@ -11,6 +13,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 */ @@ -20,7 +27,7 @@ abstract class Widget extends Window implements FocusableInterface } set { - $this->position["y"] = $value; + $this->position["x"] = $value; } } /** @@ -36,6 +43,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 +66,159 @@ 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 + { + } + + public function cycleFocusForward(): bool + { + return false; + } + + 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; + } + + 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; + } + + 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 +226,247 @@ 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; + $linesOfContent = $this->buildRenderedContentLines(); + $this->foregroundColor = $contentColor; + $topBorder = $this->buildBorderLine($this->title, true); + $bottomBorder = $this->buildBorderLine($this->help, false); + + 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, $index); + } + + $this->cursor->moveTo($leftMargin, $topMargin + count($linesOfContent) + 1); + echo $this->decorateBorderLine($bottomBorder, $contentColor); + } + + protected 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); + } + + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): 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); + } + + 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); + } + + protected function wrapWithSequence(string $content, ?string $sequence): string + { + if ($content === '' || $sequence === null) { + return $content; + } + + return $sequence . $content . Color::RESET->value; } /** * @return void */ public abstract function update(): void; -} \ No newline at end of file +} 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/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/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/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(); +}); diff --git a/tests/Unit/InputManagerTest.php b/tests/Unit/InputManagerTest.php new file mode 100644 index 0000000..e448f41 --- /dev/null +++ b/tests/Unit/InputManagerTest.php @@ -0,0 +1,51 @@ +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); +}); + +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 new file mode 100644 index 0000000..940b093 --- /dev/null +++ b/tests/Unit/InspectorPanelTest.php @@ -0,0 +1,227 @@ +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', + ]); +}); + +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 new file mode 100644 index 0000000..b8e1ca1 --- /dev/null +++ b/tests/Unit/MainPanelTest.php @@ -0,0 +1,78 @@ +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 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); + + $panel->selectTab('Sprite'); + + expect($panel->content[0])->toContain('Scene Game Sprite'); + expect($panel->content[1])->toContain('■■■■■■'); + expect(mb_strlen($panel->content[1]))->toBe($panel->innerWidth - 2); +}); 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/SceneLoaderTest.php b/tests/Unit/SceneLoaderTest.php new file mode 100644 index 0000000..63516aa --- /dev/null +++ b/tests/Unit/SceneLoaderTest.php @@ -0,0 +1,92 @@ + 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'); +}); + +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', + ]); +}); 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..14fde7f --- /dev/null +++ b/tests/Unit/WidgetTest.php @@ -0,0 +1,99 @@ + 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(); +}); + +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('│'); +});