diff --git a/examples/kanban_app.php b/examples/kanban_app.php new file mode 100644 index 0000000..acf0db1 --- /dev/null +++ b/examples/kanban_app.php @@ -0,0 +1,378 @@ +addComponent($title); + if (!empty($task['note'] ?? '')) { + $this->addComponent(new Label($task['note'], 'text-xs text-gray-500')); + } + } + + #[\Override] + public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool + { + if ($button !== 1) { + return false; + } + + $this->boardView->beginDrag($this->boardId, $this->task); + return true; + } +} + +final class KanbanBoardView extends Container +{ + private array $boards; + private Container $columnsContainer; + private array $boardViews = []; + private ?array $dragState = null; + private ?string $hoverBoardId = null; + + public function __construct( + private readonly string $storagePath, + array $initialBoards, + private readonly Label $statusLabel, + ) { + parent::__construct('flex flex-col flex-1 overflow-hidden'); + $this->boards = $initialBoards; + + $this->columnsContainer = new Container('flex flex-row gap-4 overflow-auto flex-1 pb-3'); + $this->addComponent($this->columnsContainer); + + $this->renderBoards(); + } + + public function addBoard(string $title): void + { + $title = trim($title); + if ($title === '') { + $this->statusLabel->setText('Board-Namen eingeben, um es hinzuzufügen.'); + return; + } + + $this->boards[] = [ + 'id' => uniqid('board_', true), + 'title' => $title, + 'tasks' => [], + ]; + + $this->saveBoards(); + $this->renderBoards(); + $this->statusLabel->setText('Board hinzugefügt: ' . $title); + } + + public function addTask(string $boardId, string $title): void + { + $title = trim($title); + if ($title === '') { + $this->statusLabel->setText('Bitte einen Aufgabentitel eingeben.'); + return; + } + + foreach ($this->boards as &$board) { + if ($board['id'] === $boardId) { + $board['tasks'][] = [ + 'id' => uniqid('task_', true), + 'title' => $title, + 'note' => '', + ]; + $this->saveBoards(); + $this->renderBoards(); + $this->statusLabel->setText(sprintf('Aufgabe "%s" zu "%s" hinzugefügt.', $title, $board['title'])); + return; + } + } + + $this->statusLabel->setText('Board nicht gefunden.'); + } + + public function beginDrag(string $boardId, array $task): void + { + $this->dragState = [ + 'fromBoardId' => $boardId, + 'taskId' => $task['id'], + 'taskData' => $task, + ]; + $this->statusLabel->setText('Verschiebe: ' . $task['title']); + } + + #[\Override] + public function handleMouseMove(float $mouseX, float $mouseY): void + { + parent::handleMouseMove($mouseX, $mouseY); + + if ($this->dragState === null) { + return; + } + + $target = $this->findBoardAt($mouseX, $mouseY); + if ($target !== $this->hoverBoardId) { + if ($this->hoverBoardId !== null) { + $this->setBoardHighlight($this->hoverBoardId, false); + } + if ($target !== null) { + $this->setBoardHighlight($target, true); + } + $this->hoverBoardId = $target; + } + } + + #[\Override] + public function handleMouseRelease(float $mouseX, float $mouseY, int $button): void + { + parent::handleMouseRelease($mouseX, $mouseY, $button); + + if ($this->dragState === null || $button !== 1) { + return; + } + + $dropTarget = $this->findBoardAt($mouseX, $mouseY); + $this->completeDrag($dropTarget); + } + + private function renderBoards(): void + { + $this->columnsContainer->clearChildren(); + $this->boardViews = []; + + foreach ($this->boards as $board) { + $colorHex = substr(md5($board['id']), 0, 6); + $defaultStyle = 'flex flex-col w-[260] flex-none border border-gray-400 rounded-lg p-3 gap-3 shadow-sm bg-[#' . $colorHex . ']'; + $highlightStyle = 'flex flex-col w-[260] flex-none bg-blue-50 border-2 border-blue-500 rounded-lg p-3 gap-3 shadow'; + + $debugInfo = new Label(sprintf( + "Board %s\nID: %s\nTasks: %d", + $board['title'], + $board['id'], + count($board['tasks']), + ), 'text-xs text-black whitespace-pre-wrap'); + + $column = new Container($defaultStyle); + $header = new Container('flex flex-col gap-1 bg-white/80 rounded p-2 shadow-inner'); + $header->addComponent(new Label($board['title'], 'text-lg font-semibold text-black')); + $header->addComponent(new Label(count($board['tasks']) . ' Aufgaben', 'text-xs text-gray-500')); + $header->addComponent($debugInfo); + $column->addComponent($header); + + $taskList = new Container('flex flex-col gap-2'); + foreach ($board['tasks'] as $task) { + $taskList->addComponent(new KanbanTaskCard($board['id'], $task, $this)); + } + $column->addComponent($taskList); + + $addInput = new TextInput('Neue Aufgabe', 'flex-1 border border-gray-300 rounded px-2 py-1 bg-white text-black text-sm'); + $addButton = new Button('Hinzufügen', 'px-2 py-1 bg-emerald-500 text-white rounded hover:bg-emerald-600 text-sm'); + $addButton->setOnClick(function () use ($board, $addInput): void { + $this->addTask($board['id'], $addInput->getValue()); + $addInput->setValue(''); + }); + $addRow = new Container('flex flex-row gap-2'); + $addRow->addComponent($addInput); + $addRow->addComponent($addButton); + $column->addComponent($addRow); + + $this->columnsContainer->addComponent($column); + $this->boardViews[$board['id']] = [ + 'column' => $column, + 'defaultStyle' => $defaultStyle, + 'highlightStyle' => $highlightStyle, + ]; + } + } + + private function findBoardAt(float $mouseX, float $mouseY): ?string + { + foreach ($this->boardViews as $boardId => $info) { + $viewport = $info['column']->getViewport(); + if ( + $mouseX >= $viewport->x && + $mouseX <= ($viewport->x + $viewport->width) && + $mouseY >= $viewport->y && + $mouseY <= ($viewport->y + $viewport->height) + ) { + return $boardId; + } + } + + return null; + } + + private function setBoardHighlight(string $boardId, bool $active): void + { + if (!isset($this->boardViews[$boardId])) { + return; + } + + $column = $this->boardViews[$boardId]['column']; + $style = $active ? $this->boardViews[$boardId]['highlightStyle'] : $this->boardViews[$boardId]['defaultStyle']; + $column->setStyle($style); + } + + private function completeDrag(?string $targetBoardId): void + { + $state = $this->dragState; + $this->dragState = null; + + if ($this->hoverBoardId !== null) { + $this->setBoardHighlight($this->hoverBoardId, false); + $this->hoverBoardId = null; + } + + if (!$state) { + return; + } + + if ($targetBoardId === null) { + $this->statusLabel->setText('Ziehen abgebrochen.'); + return; + } + + if ($targetBoardId === $state['fromBoardId']) { + $this->statusLabel->setText('Aufgabe blieb im gleichen Board.'); + return; + } + + $movedTask = null; + foreach ($this->boards as &$board) { + if ($board['id'] === $state['fromBoardId']) { + foreach ($board['tasks'] as $index => $task) { + if ($task['id'] === $state['taskId']) { + $movedTask = $task; + array_splice($board['tasks'], $index, 1); + break 2; + } + } + } + } + + if ($movedTask === null) { + $this->statusLabel->setText('Aufgabe konnte nicht verschoben werden.'); + return; + } + + foreach ($this->boards as &$board) { + if ($board['id'] === $targetBoardId) { + $board['tasks'][] = $movedTask; + break; + } + } + + $this->saveBoards(); + $this->renderBoards(); + + $this->statusLabel->setText(sprintf('"%s" nach "%s" verschoben.', $movedTask['title'], $this->getBoardTitle($targetBoardId))); + } + + private function getBoardTitle(string $boardId): string + { + foreach ($this->boards as $board) { + if ($board['id'] === $boardId) { + return $board['title']; + } + } + + return $boardId; + } + + private function saveBoards(): void + { + file_put_contents( + $this->storagePath, + json_encode($this->boards, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), + LOCK_EX, + ); + } +} + +$storagePath = __DIR__ . '/kanban_data.json'; + +$boards = []; +if (is_file($storagePath)) { + $raw = file_get_contents($storagePath); + $decoded = json_decode($raw ?: '[]', true); + if (is_array($decoded)) { + $boards = $decoded; + } +} + +if (empty($boards)) { + $boards = [ + [ + 'id' => uniqid('board_', true), + 'title' => 'Backlog', + 'tasks' => [ + ['id' => uniqid('task_', true), 'title' => 'Idee sammeln', 'note' => ''], + ['id' => uniqid('task_', true), 'title' => 'Mockups skizzieren', 'note' => ''], + ], + ], + [ + 'id' => uniqid('board_', true), + 'title' => 'In Arbeit', + 'tasks' => [ + ['id' => uniqid('task_', true), 'title' => 'API anbinden', 'note' => ''], + ], + ], + [ + 'id' => uniqid('board_', true), + 'title' => 'Erledigt', + 'tasks' => [ + ['id' => uniqid('task_', true), 'title' => 'UI Grundlayout', 'note' => ''], + ], + ], + ]; + file_put_contents( + $storagePath, + json_encode($boards, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), + LOCK_EX, + ); +} + +$app = new Application(); +$window = new Window('Kanban Beispiel', 1200, 800); + +$root = new Container('flex flex-col h-full w-full bg-gray-100 gap-4 p-4'); + +$title = new Label('Kanban Board', 'text-3xl font-bold text-black'); +$subTitle = new Label('Boards hinzufügen, Aufgaben erstellen und per Drag & Drop verschieben.', 'text-base text-gray-700'); +$root->addComponent($title); +$root->addComponent($subTitle); + +$statusLabel = new Label('Bereit.', 'text-sm text-gray-600'); +$kanbanView = new KanbanBoardView($storagePath, $boards, $statusLabel); + +$boardInput = new TextInput('Neues Board', 'flex-1 border border-gray-300 rounded px-3 py-2 bg-white text-black'); +$boardAddButton = new Button('Board hinzufügen', 'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700'); +$boardAddButton->setOnClick(function () use ($kanbanView, $boardInput, $statusLabel): void { + $kanbanView->addBoard($boardInput->getValue()); + $boardInput->setValue(''); +}); +$boardRow = new Container('flex flex-row gap-3 items-center'); +$boardRow->addComponent($boardInput); +$boardRow->addComponent($boardAddButton); +$root->addComponent($boardRow); + +$root->addComponent($kanbanView); +$root->addComponent($statusLabel); + +$window->setRoot($root); +$app->addWindow($window); +$app->run(); diff --git a/examples/kanban_data.json b/examples/kanban_data.json new file mode 100644 index 0000000..44623e4 --- /dev/null +++ b/examples/kanban_data.json @@ -0,0 +1,40 @@ +[ + { + "id": "board_691661d89de624.46726087", + "title": "Backlog", + "tasks": [ + { + "id": "task_691661d89de735.41071535", + "title": "Idee sammeln", + "note": "" + }, + { + "id": "task_691661d89de7a0.19655479", + "title": "Mockups skizzieren", + "note": "" + } + ] + }, + { + "id": "board_691661d89de806.79123800", + "title": "In Arbeit", + "tasks": [ + { + "id": "task_691661d89de858.46479539", + "title": "API anbinden", + "note": "" + } + ] + }, + { + "id": "board_691661d89de894.20053237", + "title": "Erledigt", + "tasks": [ + { + "id": "task_691661d89de8e0.86926319", + "title": "UI Grundlayout", + "note": "" + } + ] + } +] \ No newline at end of file diff --git a/kanban.png b/kanban.png new file mode 100644 index 0000000..6fd0180 Binary files /dev/null and b/kanban.png differ diff --git a/src/Tailwind/Parser/Height.php b/src/Tailwind/Parser/Height.php index 1c8c78e..4bcf5b8 100644 --- a/src/Tailwind/Parser/Height.php +++ b/src/Tailwind/Parser/Height.php @@ -13,6 +13,11 @@ class Height implements Parser $value = -1; $unit = Unit::Pixel; $found = false; + + if (preg_match('/(min-)?h-\[(\d+)\]/', $style, $output_array)) { + $value = (int)$output_array[2]; + $found = true; + } preg_match_all('/h-(\d*)\/(\d*)/', $style, $output_array); if (count($output_array[0]) > 0) { $value1 = (int)$output_array[1][0]; diff --git a/src/Tailwind/Parser/Width.php b/src/Tailwind/Parser/Width.php index 9f3dce7..b4e54e9 100644 --- a/src/Tailwind/Parser/Width.php +++ b/src/Tailwind/Parser/Width.php @@ -13,6 +13,11 @@ class Width implements Parser $value = -1; $unit = Unit::Pixel; $found = false; + + if (preg_match('/(min-)?w-\[(\d+)\]/', $style, $output_array)) { + $value = (int)$output_array[2]; + $found = true; + } preg_match_all('/w-(\d*)\/(\d*)/', $style, $output_array); if (count($output_array[0]) > 0) { $value1 = (int)$output_array[1][0]; diff --git a/src/Ui/Component.php b/src/Ui/Component.php index 20d482c..5a2a87d 100644 --- a/src/Ui/Component.php +++ b/src/Ui/Component.php @@ -70,6 +70,16 @@ abstract class Component $this->contentViewport = clone $this->viewport; } + public function setStyle(string $style): void + { + if ($this->style === $style) { + return; + } + + $this->style = $style; + $this->markDirty(true); + } + /** * Destructor - clean up resources */ @@ -908,6 +918,10 @@ abstract class Component */ private function createShadowTexture(&$renderer, int $width, int $height, int $blurRadius, int $alpha, int $r = 0, int $g = 0, int $b = 0): mixed { + if ($width <= 0 || $height <= 0) { + return null; + } + // Create alpha map (single channel) - start with transparent $alphaMap = array_fill(0, $width * $height, 0); diff --git a/src/Ui/Widget/Button.php b/src/Ui/Widget/Button.php index 2b70bf5..4ccbffb 100644 --- a/src/Ui/Widget/Button.php +++ b/src/Ui/Widget/Button.php @@ -82,7 +82,7 @@ class Button extends Container public function setStyle(string $style): void { - $this->style = $style; + parent::setStyle($style); } /** diff --git a/src/Ui/Widget/Container.php b/src/Ui/Widget/Container.php index 5c0b378..329010f 100644 --- a/src/Ui/Widget/Container.php +++ b/src/Ui/Widget/Container.php @@ -519,6 +519,8 @@ class Container extends Component private function renderScrollbars(&$renderer, array $overflow): void { + $scrollbarColor = [120, 120, 120, 230]; + // Vertical scrollbar if ($overflow['y']) { $scrollbarHeight = $this->contentViewport->height;