sdl3/examples/kanban_app.php
2025-11-14 22:23:32 +01:00

422 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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,
bool $selected = false,
) {
$baseStyle = 'bg-white border rounded px-3 py-2 shadow cursor-pointer';
$style = $selected ? ($baseStyle . ' border-blue-500 bg-blue-50') : ($baseStyle . ' border-gray-200');
parent::__construct($style);
$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'));
}
}
public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool
{
if ($button !== 1) {
return false;
}
$this->boardView->selectTask($this->boardId, $this->task);
return true;
}
}
final class KanbanBoardHeader extends Container
{
public function __construct(
private readonly string $boardId,
private KanbanBoardView $boardView,
string $title,
int $taskCount,
) {
parent::__construct('flex flex-col gap-1 cursor-pointer');
$this->addComponent(new Label($title, 'text-lg font-bold text-black'));
$this->addComponent(new Label(sprintf('%d Aufgaben', $taskCount), 'text-xs text-gray-700'));
$this->addComponent(new Label('Hierhin verschieben (Klick)', 'text-[10] text-gray-500'));
}
public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool
{
if ($button !== 1) {
return false;
}
// Nur reagieren, wenn der Klick innerhalb des Header-Viewports liegt
$vp = $this->getViewport();
$inside =
$mouseX >= $vp->x &&
$mouseX <= ($vp->x + $vp->width) &&
$mouseY >= $vp->y &&
$mouseY <= ($vp->y + $vp->height);
if (!$inside) {
return false;
}
if (defined('DEBUG_EVENTS') && DEBUG_EVENTS) {
error_log(sprintf('[KanbanHeader] Click on board "%s"', $this->boardId));
}
$this->boardView->moveSelectedToBoard($this->boardId);
return true;
}
}
final class KanbanBoardView extends Container
{
private array $boards;
private array $boardViews = [];
private null|string $selectedTaskId = null;
private null|string $selectedBoardId = null;
public function __construct(
private readonly string $storagePath,
array $initialBoards,
private readonly Label $statusLabel,
) {
parent::__construct('flex flex-row gap-4 flex-1');
$this->boards = $initialBoards;
$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 selectTask(string $boardId, array $task): void
{
$this->selectedBoardId = $boardId;
$this->selectedTaskId = $task['id'];
$msg = sprintf('Ausgewählt: "%s" in "%s"', $task['title'], $this->getBoardTitle($boardId));
$this->statusLabel->setText($msg);
if (defined('DEBUG_EVENTS') && DEBUG_EVENTS) {
error_log('[Kanban] ' . $msg);
}
$this->renderBoards();
}
public function moveSelectedToBoard(string $targetBoardId): void
{
if ($this->selectedTaskId === null) {
if (defined('DEBUG_EVENTS') && DEBUG_EVENTS) {
error_log('[Kanban] moveSelectedToBoard called without selectedTaskId');
}
$this->statusLabel->setText('Keine Aufgabe ausgewählt.');
return;
}
if ($this->selectedBoardId === $targetBoardId) {
if (defined('DEBUG_EVENTS') && DEBUG_EVENTS) {
error_log('[Kanban] moveSelectedToBoard target is same board');
}
$this->statusLabel->setText('Aufgabe ist bereits in diesem Board.');
return;
}
$movedTask = null;
foreach ($this->boards as &$board) {
if ($board['id'] === $this->selectedBoardId) {
foreach ($board['tasks'] as $index => $task) {
if ($task['id'] === $this->selectedTaskId) {
$movedTask = $task;
array_splice($board['tasks'], $index, 1);
break 2;
}
}
}
}
if ($movedTask === null) {
if (defined('DEBUG_EVENTS') && DEBUG_EVENTS) {
error_log('[Kanban] moveSelectedToBoard: task not found in source board');
}
$this->statusLabel->setText('Ausgewählte Aufgabe konnte nicht gefunden werden.');
return;
}
foreach ($this->boards as &$board) {
if ($board['id'] === $targetBoardId) {
$board['tasks'][] = $movedTask;
break;
}
}
// Nach erfolgreichem Verschieben Auswahl zurücksetzen,
// damit weitere Board-Klicks den Task nicht erneut verschieben.
$this->selectedBoardId = null;
$this->selectedTaskId = null;
$this->saveBoards();
$this->renderBoards();
$msg = sprintf(
'"%s" nach "%s" verschoben.',
$movedTask['title'],
$this->getBoardTitle($targetBoardId),
);
$this->statusLabel->setText($msg);
if (defined('DEBUG_EVENTS') && DEBUG_EVENTS) {
error_log('[Kanban] ' . $msg);
}
}
private function renderBoards(): void
{
$this->clearChildren();
$this->boardViews = [];
foreach ($this->boards as $board) {
$defaultStyle = 'flex flex-col w-[340] flex-none bg-white border border-gray-300 rounded-2xl p-5 gap-3';
$highlightStyle = 'flex flex-col w-[340] flex-none bg-blue-50 border-2 border-blue-500 rounded-2xl p-5 gap-3';
$column = new Container($defaultStyle);
// Header mit Titel und Task-Anzahl, klickbar als Drop-Ziel
$header = new KanbanBoardHeader($board['id'], $this, $board['title'], count($board['tasks']));
$column->addComponent($header);
// Task-Karten (Buttons) mit Auswahl-Hervorhebung
if (!empty($board['tasks'])) {
$taskList = new Container('flex flex-col gap-3 mt-3');
foreach ($board['tasks'] as $task) {
$isSelected = $this->selectedTaskId === $task['id'];
$style = $isSelected
? 'w-full text-left bg-blue-50 border border-blue-500 rounded-lg px-4 py-3 shadow-md cursor-pointer'
: 'w-full text-left bg-white border border-gray-200 rounded-lg px-4 py-3 shadow-md cursor-pointer';
$button = new Button($task['title'], $style);
$button->setOnClick(function () use ($board, $task): void {
// Debug-Ausgabe in Statuszeile, um Klicks sicher zu sehen
$this->statusLabel->setText(sprintf(
'Task-Klick: "%s" in "%s"',
$task['title'],
$this->getBoardTitle($board['id']),
));
$this->selectTask($board['id'], $task);
});
$taskList->addComponent($button);
}
$column->addComponent($taskList);
}
$this->addComponent($column);
$this->boardViews[$board['id']] = [
'column' => $column,
'defaultStyle' => $defaultStyle,
'highlightStyle' => $highlightStyle,
];
}
}
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);
}
define('DEBUG_EVENTS', true);
$app = new Application();
$window = new Window('Kanban Beispiel', 1200, 800);
$root = new Container('flex flex-col h-full w-full bg-gray-100 gap-6 p-6');
$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);
// SDL_Window PixelDensity und DisplayScale im Status anzeigen (falls verfügbar)
$densityText = '';
if (function_exists('sdl_get_window_pixel_density')) {
$density = sdl_get_window_pixel_density($window->getWindowResource());
$densityText .= sprintf('PixelDensity: %.2f', $density);
}
if (function_exists('sdl_get_window_display_scale')) {
$scale = sdl_get_window_display_scale($window->getWindowResource());
$densityText .= ($densityText !== '' ? ' | ' : '') . sprintf('DisplayScale: %.2f', $scale);
}
if (function_exists('sdl_get_display_content_scale')) {
$contentScale = sdl_get_display_content_scale($window->getWindowResource());
$densityText .= ($densityText !== '' ? ' | ' : '') . sprintf('ContentScale: %.2f', $contentScale);
}
// Fallback: effektiven Scale aus WindowSize vs. WindowSizeInPixels berechnen
if (
function_exists('sdl_get_window_size') &&
function_exists('sdl_get_window_size_in_pixels')
) {
$logical = sdl_get_window_size($window->getWindowResource());
$pixels = sdl_get_window_size_in_pixels($window->getWindowResource());
if (is_array($logical) && is_array($pixels) && $logical[0] > 0 && $logical[1] > 0) {
$scaleX = $pixels[0] / $logical[0];
$scaleY = $pixels[1] / $logical[1];
$densityText .= ($densityText !== '' ? ' | ' : '') .
sprintf('EffectiveScale: %.2f x %.2f', $scaleX, $scaleY);
}
}
if (function_exists('sdl_get_current_video_driver')) {
$driver = sdl_get_current_video_driver();
if ($driver !== false) {
$densityText .= ($densityText !== '' ? ' | ' : '') . 'VideoDriver: ' . $driver;
}
}
if ($densityText !== '') {
$statusLabel->setText('Bereit. ' . $densityText);
}
$scaleInput = new TextInput('Scale (z.B. 1.0 oder 2.0)', 'w-40 border border-gray-300 rounded px-3 py-2 bg-white text-black');
$scaleInput->setOnChange(function (string $value) use ($statusLabel): void {
$value = trim($value);
if ($value === '') {
return;
}
$scale = (float) $value;
if ($scale <= 0.1 || $scale > 4.0) {
$statusLabel->setText('Skalierungsfaktor muss zwischen 0.1 und 4.0 liegen.');
return;
}
putenv('PHPNATIVE_UI_SCALE=' . $scale);
$statusLabel->setText(sprintf('Skalierung gesetzt auf %.2f bitte App neu starten.', $scale));
});
$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($scaleInput);
$boardRow->addComponent($boardAddButton);
$root->addComponent($boardRow);
$root->addComponent($kanbanView);
$root->addComponent($statusLabel);
$window->setRoot($root);
$app->addWindow($window);
$app->run();