This commit is contained in:
Thomas Peterson 2025-11-14 09:23:48 +01:00
parent 15186e0005
commit eced006d55
8 changed files with 445 additions and 1 deletions

378
examples/kanban_app.php Normal file
View File

@ -0,0 +1,378 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use PHPNative\Framework\Application;
use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Widget\TextInput;
use PHPNative\Ui\Window;
final class KanbanTaskCard extends Container
{
public function __construct(
private readonly string $boardId,
private readonly array $task,
private KanbanBoardView $boardView,
) {
parent::__construct('bg-white border border-gray-200 rounded shadow px-3 py-2 cursor-pointer');
$title = new Label($task['title'], 'text-sm text-black');
$this->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();

40
examples/kanban_data.json Normal file
View File

@ -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": ""
}
]
}
]

BIN
kanban.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View File

@ -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];

View File

@ -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];

View File

@ -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);

View File

@ -82,7 +82,7 @@ class Button extends Container
public function setStyle(string $style): void
{
$this->style = $style;
parent::setStyle($style);
}
/**

View File

@ -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;