sdl3/src/Ui/Widget/Table.php
2025-11-29 22:10:58 +01:00

277 lines
9.0 KiB
PHP

<?php
namespace PHPNative\Ui\Widget;
use PHPNative\Framework\TextRenderer;
class Table extends Container
{
private array $columns = [];
private array $rows = [];
private Container $headerContainer;
private Container $bodyContainer;
private null|int $selectedRowIndex = null;
private $onRowSelect = null;
private null|string $sortColumn = null;
private bool $sortAscending = true;
public function __construct(string $style = '')
{
// Table selbst ist kein Flex-Container; sie wird als Kind
// in einem flex-Layout benutzt (z.B. flex-1), aber intern
// werden Header und Body ganz normal vertikal gestapelt.
parent::__construct('w-full' . $style);
// Create header container
$this->headerContainer = new Container('flex flex-row w-full bg-gray-200 border-b-2 border-gray-400');
$this->addComponent($this->headerContainer);
// Create body container (scrollable)
// Use flex-1 to fill available space, overflow-auto to enable scrolling
$this->bodyContainer = new Container('flex flex-col w-full overflow-auto flex-1');
$this->addComponent($this->bodyContainer);
}
public function layout(null|TextRenderer $textRenderer = null): void
{
$this->headerContainer->layout($textRenderer);
$this->bodyContainer->layout($textRenderer);
parent::layout($textRenderer);
}
/**
* Define columns
*
* @param array $columns Array of column definitions ['key' => string, 'title' => string, 'width' => int|null]
*/
public function setColumns(array $columns): void
{
$this->columns = $columns;
$this->headerContainer->clearChildren();
foreach ($columns as $column) {
$key = $column['key'];
$title = $column['title'] ?? $key;
$width = $column['width'] ?? null;
$style = 'px-4 py-2 text-black font-bold border-r border-gray-300 hover:bg-gray-300 cursor-pointer';
if ($width) {
$style .= ' w-' . ((int) ($width / 4));
} else {
$style .= ' flex-1';
}
$headerLabel = new ClickableHeaderLabel($key, $this, $title, $style);
$this->headerContainer->addComponent($headerLabel);
}
}
/**
* Set table data
*
* @param array $data Array of row data (associative arrays)
* @param bool $preserveScroll Whether to preserve scroll position
*/
public function setData(array $data, bool $preserveScroll = false): void
{
$this->rows = $data;
$scrollPosition = null;
if ($preserveScroll) {
$scrollPosition = $this->bodyContainer->getScrollPosition();
}
$this->bodyContainer->clearChildren();
foreach ($data as $rowIndex => $row) {
$this->addRow($row, $rowIndex);
}
if ($preserveScroll && $scrollPosition !== null) {
$this->bodyContainer->setScrollPosition($scrollPosition['x'], $scrollPosition['y']);
}
// Debug-Ausgabe: Größe des Tabellen-Body und Viewport
$bodySize = $this->bodyContainer->getContentSize();
$bodyViewport = $this->bodyContainer->getContentViewport();
error_log(
sprintf(
'Table body debug: rows=%d, contentHeight=%d, viewportHeight=%d',
count($data),
(int) ($bodySize['height'] ?? 0),
(int) $bodyViewport->height,
),
);
}
/**
* Add a single row
*/
private function addRow(array $rowData, int $rowIndex): void
{
$isSelected = $rowIndex === $this->selectedRowIndex;
$rowStyle = 'flex flex-row border-b border-gray-200 hover:bg-gray-400';
if ($isSelected) {
$rowStyle .= ' bg-blue-100';
}
$rowContainer = new Container($rowStyle);
// Check if any column has custom render (interactive components)
$hasCustomRender = false;
foreach ($this->columns as $column) {
if (isset($column['render']) && is_callable($column['render'])) {
$hasCustomRender = true;
break;
}
}
// Enable texture caching for table rows only if no custom render
// Custom renders may contain interactive components (buttons) that need event handling
if (!$hasCustomRender) {
$rowContainer->setUseTextureCache(true);
}
foreach ($this->columns as $column) {
$key = $column['key'];
$value = $rowData[$key] ?? '';
$width = $column['width'] ?? null;
$cellStyle = 'px-4 py-2 text-black border-r border-gray-300';
if ($width) {
$cellStyle .= ' w-' . ((int) ($width / 4));
} else {
$cellStyle .= ' flex-1';
}
// Check if column has custom render function
if (isset($column['render']) && is_callable($column['render'])) {
// Wrap custom cell content in a container so that
// width/padding styles are applied consistently.
$cellContent = $column['render']($rowData, $rowIndex);
$cellContainer = new Container($cellStyle);
$cellContainer->addComponent($cellContent);
$rowContainer->addComponent($cellContainer);
} else {
$cellLabel = new Label((string) $value, $cellStyle);
$rowContainer->addComponent($cellLabel);
}
}
// Make row clickable
$clickHandler = new class($rowContainer, $rowIndex, $this) extends Container {
private int $rowIndex;
private Table $table;
public function __construct(Container $rowContainer, int $rowIndex, Table $table)
{
$this->rowIndex = $rowIndex;
$this->table = $table;
parent::__construct('');
$this->addComponent($rowContainer);
}
public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool
{
// First, let children (like buttons) handle the click
$handled = parent::handleMouseClick($mouseX, $mouseY, $button);
if ($handled) {
return true;
}
// If no child handled it, check if click is within row bounds
if (
$mouseX >= $this->viewport->x &&
$mouseX <= ($this->viewport->x + $this->viewport->width) &&
$mouseY >= $this->viewport->y &&
$mouseY <= ($this->viewport->y + $this->viewport->height)
) {
$this->table->selectRow($this->rowIndex);
return true;
}
return false;
}
};
$this->bodyContainer->addComponent($clickHandler);
}
/**
* Sort by column
*/
public function sortByColumn(string $columnKey): void
{
// Toggle sort direction if clicking same column
if ($this->sortColumn === $columnKey) {
$this->sortAscending = !$this->sortAscending;
} else {
$this->sortColumn = $columnKey;
$this->sortAscending = true;
}
// Sort the data
$sortedRows = $this->rows;
usort($sortedRows, function ($a, $b) use ($columnKey) {
$aVal = $a[$columnKey] ?? '';
$bVal = $b[$columnKey] ?? '';
// Natural sort for strings/numbers
$result = strnatcasecmp((string) $aVal, (string) $bVal);
return $this->sortAscending ? $result : -$result;
});
// Re-render with sorted data (reset scroll)
$this->setData($sortedRows, false);
}
/**
* Select a row
*/
public function selectRow(int $rowIndex): void
{
// Remember current scroll position of the body so that selection
// does not reset scrolling back to the top.
$scrollPosition = $this->bodyContainer->getScrollPosition();
$this->selectedRowIndex = $rowIndex;
// Trigger callback
if ($this->onRowSelect !== null) {
($this->onRowSelect)($rowIndex, $this->rows[$rowIndex] ?? null);
}
// Re-render rows to update selection but keep scroll
$this->setData($this->rows, true);
// Restore scroll position
$this->bodyContainer->setScrollPosition($scrollPosition['x'], $scrollPosition['y']);
}
/**
* Set row select callback
*/
public function setOnRowSelect(callable $callback): void
{
$this->onRowSelect = $callback;
}
/**
* Get selected row index
*/
public function getSelectedRowIndex(): null|int
{
return $this->selectedRowIndex;
}
/**
* Get selected row data
*/
public function getSelectedRow(): null|array
{
return $this->selectedRowIndex !== null ? ($this->rows[$this->selectedRowIndex] ?? null) : null;
}
}