Fix Gap and Modal Position

This commit is contained in:
Thomas Peterson 2025-11-10 09:50:49 +01:00
parent 43a140c08d
commit 6d969944dc
12 changed files with 488 additions and 22 deletions

62
examples/gap_test.php Normal file
View File

@ -0,0 +1,62 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use PHPNative\Framework\Application;
use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Window;
$app = new Application();
$window = new Window('Gap Test', 600, 400);
// Main container
$mainContainer = new Container('flex flex-col p-4 bg-gray-100 gap-4');
// Test 1: Row with gap-2
$test1 = new Container('flex flex-col gap-2');
$test1->addComponent(new \PHPNative\Ui\Widget\Label('Test 1: flex-row gap-2', 'text-lg font-bold'));
$row1 = new Container('flex flex-row gap-2 bg-white p-4');
$row1->addComponent(new Button('Button 1', 'px-4 py-2 bg-blue-600 text-white rounded'));
$row1->addComponent(new Button('Button 2', 'px-4 py-2 bg-green-600 text-white rounded'));
$test1->addComponent($row1);
$mainContainer->addComponent($test1);
// Test 2: Row with gap-4
$test2 = new Container('flex flex-col gap-2');
$test2->addComponent(new \PHPNative\Ui\Widget\Label('Test 2: flex-row gap-4', 'text-lg font-bold'));
$row2 = new Container('flex flex-row gap-4 bg-white p-4');
$row2->addComponent(new Button('Button A', 'px-4 py-2 bg-red-600 text-white rounded'));
$row2->addComponent(new Button('Button B', 'px-4 py-2 bg-purple-600 text-white rounded'));
$test2->addComponent($row2);
$mainContainer->addComponent($test2);
// Test 3: Row with gap-8
$test3 = new Container('flex flex-col gap-2');
$test3->addComponent(new \PHPNative\Ui\Widget\Label('Test 3: flex-row gap-8', 'text-lg font-bold'));
$row3 = new Container('flex flex-row gap-8 bg-white p-4');
$row3->addComponent(new Button('Btn X', 'px-4 py-2 bg-orange-600 text-white rounded'));
$row3->addComponent(new Button('Btn Y', 'px-4 py-2 bg-pink-600 text-white rounded'));
$test3->addComponent($row3);
$mainContainer->addComponent($test3);
// Test 4: Column with gap-4
$test4 = new Container('flex flex-col gap-2');
$test4->addComponent(new \PHPNative\Ui\Widget\Label('Test 4: flex-col gap-4', 'text-lg font-bold'));
$col = new Container('flex flex-col gap-4 bg-white p-4');
$col->addComponent(new Button('Top Button', 'px-4 py-2 bg-teal-600 text-white rounded'));
$col->addComponent(new Button('Bottom Button', 'px-4 py-2 bg-indigo-600 text-white rounded'));
$test4->addComponent($col);
$mainContainer->addComponent($test4);
$window->setRoot($mainContainer);
$app->addWindow($window);
echo "Gap Test Example\n";
echo "You should see:\n";
echo "- Row 1: 2 buttons with 8px gap (gap-2)\n";
echo "- Row 2: 2 buttons with 16px gap (gap-4)\n";
echo "- Row 3: 2 buttons with 32px gap (gap-8)\n";
echo "- Column: 2 buttons stacked with 16px gap (gap-4)\n\n";
$app->run();

View File

@ -4,6 +4,7 @@ require_once __DIR__ . '/../vendor/autoload.php';
use PHPNative\Framework\Application;
use PHPNative\Framework\IconFontRegistry;
use PHPNative\Framework\Settings;
use PHPNative\Tailwind\Data\Icon as IconName;
use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Container;
@ -42,7 +43,10 @@ if ($iconFontPath !== null) {
$app = new Application();
$window = new Window('Windows Application Example', 800, 600);
$currentApiKey = '';
// Initialize settings
$settings = new Settings('WindowsAppExample');
$currentApiKey = $settings->get('api_key', '');
/** @var Label|null $statusLabel */
$statusLabel = null;
@ -53,7 +57,7 @@ $mainContainer = new Container('flex flex-col bg-gray-100');
// Modal dialog setup (hidden by default)
$apiKeyInput = new TextInput('API Key', 'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black');
$modalDialog = new Container('bg-white border border-gray-300 rounded p-6 flex flex-col w-96 gap-3 shadow-lg');
$modalDialog = new Container('bg-white rounded-lg p-6 flex flex-col w-96 gap-3');
$modalDialog->addComponent(new Label('API Einstellungen', 'text-xl font-bold text-black'));
$modalDialog->addComponent(new Label(
'Bitte gib deinen API Key ein, um externe Dienste zu verbinden.',
@ -65,17 +69,16 @@ $fieldContainer->addComponent(new Label('API Key', 'text-sm text-gray-600'));
$fieldContainer->addComponent($apiKeyInput);
$modalDialog->addComponent($fieldContainer);
$buttonRow = new Container('flex flex-row justify-end gap-2');
$buttonRow = new Container('flex flex-row gap-2 justify-end');
$cancelButton = new Button('Abbrechen', 'px-4 py-2 bg-gray-200 text-black rounded hover:bg-gray-300');
$saveButton = new Button('Speichern', 'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center');
$saveIcon = new Icon(IconName::save, 18, 'text-white');
$saveIcon = new Icon(IconName::save, 18, 'text-white mr-2');
$saveButton->setIcon($saveIcon);
$buttonRow->addComponent($cancelButton);
$buttonRow->addComponent($saveButton);
$modalDialog->addComponent($buttonRow);
$modal = new Modal($modalDialog);
$modal->setBackdropAlpha(180);
$modal = new Modal($modalDialog, 'flex items-center justify-center bg-black', 200);
// === 1. MenuBar ===
$menuBar = new MenuBar();
@ -113,7 +116,7 @@ $tabContainer = new TabContainer('flex-1');
// Tab 1: Table with data
$tab1 = new Container('flex flex-col p-4');
$table = new Table(style: 'bg-lime-200');
$table = new Table(style: '');
$table->setColumns([
['key' => 'id', 'title' => 'ID', 'width' => 80],
@ -186,13 +189,18 @@ $cancelButton->setOnClick(function () use ($menuBar, $modal) {
$modal->setVisible(false);
});
$saveButton->setOnClick(function () use (&$currentApiKey, $apiKeyInput, $menuBar, $modal, &$statusLabel) {
$saveButton->setOnClick(function () use ($settings, &$currentApiKey, $apiKeyInput, $menuBar, $modal, &$statusLabel) {
$currentApiKey = trim($apiKeyInput->getValue());
// Save to settings
$settings->set('api_key', $currentApiKey);
$settings->save();
if ($statusLabel !== null) {
$masked = strlen($currentApiKey) > 4
? (str_repeat('*', max(0, strlen($currentApiKey) - 4)) . substr($currentApiKey, -4))
: $currentApiKey;
$statusLabel->setText('API-Key gespeichert: ' . $masked);
$statusLabel->setText('API-Key gespeichert: ' . $masked . ' (' . $settings->getPath() . ')');
}
$menuBar->closeAllMenus();
$modal->setVisible(false);

213
src/Framework/Settings.php Normal file
View File

@ -0,0 +1,213 @@
<?php
namespace PHPNative\Framework;
/**
* Cross-platform settings manager
* Stores application settings in JSON format in platform-appropriate directories
*/
class Settings
{
private string $appName;
private string $configPath;
private array $data = [];
private bool $loaded = false;
public function __construct(string $appName)
{
$this->appName = $appName;
$this->configPath = $this->getConfigPath();
$this->ensureConfigDirectory();
}
/**
* Get the platform-specific configuration directory
*/
private function getConfigPath(): string
{
$os = PHP_OS_FAMILY;
switch ($os) {
case 'Windows':
// Windows: %APPDATA%\AppName\config.json
$appData = getenv('APPDATA') ?: (getenv('USERPROFILE') . '\\AppData\\Roaming');
return $appData . DIRECTORY_SEPARATOR . $this->appName . DIRECTORY_SEPARATOR . 'config.json';
case 'Darwin':
// macOS: ~/Library/Application Support/AppName/config.json
$home = getenv('HOME') ?: posix_getpwuid(posix_getuid())['dir'];
return $home . '/Library/Application Support/' . $this->appName . '/config.json';
case 'Linux':
default:
// Linux: ~/.config/AppName/config.json (XDG Base Directory)
$xdgConfig = getenv('XDG_CONFIG_HOME');
if (!$xdgConfig) {
$home = getenv('HOME') ?: posix_getpwuid(posix_getuid())['dir'];
$xdgConfig = $home . '/.config';
}
return $xdgConfig . DIRECTORY_SEPARATOR . $this->appName . DIRECTORY_SEPARATOR . 'config.json';
}
}
/**
* Ensure the configuration directory exists
*/
private function ensureConfigDirectory(): void
{
$dir = dirname($this->configPath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
}
/**
* Load settings from disk
*/
private function load(): void
{
if ($this->loaded) {
return;
}
if (file_exists($this->configPath)) {
$contents = file_get_contents($this->configPath);
$decoded = json_decode($contents, true);
if (is_array($decoded)) {
$this->data = $decoded;
}
}
$this->loaded = true;
}
/**
* Save settings to disk
*/
public function save(): bool
{
$this->ensureConfigDirectory();
$json = json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
if ($json === false) {
return false;
}
return file_put_contents($this->configPath, $json, LOCK_EX) !== false;
}
/**
* Get a setting value
*/
public function get(string $key, mixed $default = null): mixed
{
$this->load();
// Support dot notation for nested keys (e.g., "api.key")
$keys = explode('.', $key);
$value = $this->data;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Set a setting value
*/
public function set(string $key, mixed $value): void
{
$this->load();
// Support dot notation for nested keys
$keys = explode('.', $key);
$current = &$this->data;
foreach ($keys as $i => $k) {
if ($i === count($keys) - 1) {
// Last key - set the value
$current[$k] = $value;
} else {
// Create nested array if it doesn't exist
if (!isset($current[$k]) || !is_array($current[$k])) {
$current[$k] = [];
}
$current = &$current[$k];
}
}
}
/**
* Check if a setting exists
*/
public function has(string $key): bool
{
$this->load();
$keys = explode('.', $key);
$value = $this->data;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return false;
}
$value = $value[$k];
}
return true;
}
/**
* Remove a setting
*/
public function remove(string $key): void
{
$this->load();
$keys = explode('.', $key);
$current = &$this->data;
foreach ($keys as $i => $k) {
if ($i === count($keys) - 1) {
// Last key - remove it
unset($current[$k]);
} else {
if (!isset($current[$k]) || !is_array($current[$k])) {
return;
}
$current = &$current[$k];
}
}
}
/**
* Get all settings as an array
*/
public function all(): array
{
$this->load();
return $this->data;
}
/**
* Clear all settings
*/
public function clear(): void
{
$this->data = [];
$this->loaded = true;
}
/**
* Get the path to the config file
*/
public function getPath(): string
{
return $this->configPath;
}
}

View File

@ -4,7 +4,15 @@ namespace PHPNative\Tailwind\Data;
enum Icon:int
{
case plus = 57669;
case save = 57697;
// FontAwesome 6 Free Solid Unicode code points
case plus = 0xf067; // f067 - plus
case save = 0xf0c7; // f0c7 - floppy-disk (save)
case check = 0xf00c; // f00c - check
case times = 0xf00d; // f00d - xmark (close)
case edit = 0xf044; // f044 - pen-to-square (edit)
case trash = 0xf2ed; // f2ed - trash-can
case search = 0xf002; // f002 - magnifying-glass (search)
case user = 0xf007; // f007 - user
case cog = 0xf013; // f013 - gear (settings)
case home = 0xf015; // f015 - house (home)
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace PHPNative\Tailwind\Parser;
class Gap implements Parser
{
public static function parse(string $style): ?\PHPNative\Tailwind\Style\Gap
{
$x = null;
$y = null;
// gap-{n} - both x and y
preg_match_all('/\bgap-(\d+)\b/', $style, $output_array);
if (count($output_array[0]) > 0) {
$value = (int)$output_array[1][0] * 4; // Tailwind uses a 4px scale
$x = $value;
$y = $value;
}
// gap-x-{n}
preg_match_all('/\bgap-x-(\d+)\b/', $style, $output_array);
if (count($output_array[0]) > 0) {
$x = (int)$output_array[1][0] * 4;
}
// gap-y-{n}
preg_match_all('/\bgap-y-(\d+)\b/', $style, $output_array);
if (count($output_array[0]) > 0) {
$y = (int)$output_array[1][0] * 4;
}
if ($x !== null || $y !== null) {
$gap = new \PHPNative\Tailwind\Style\Gap($x ?? 0, $y ?? 0);
error_log("Gap parsed from '$style': x={$gap->x}, y={$gap->y}");
return $gap;
}
return null;
}
public static function merge(\PHPNative\Tailwind\Style\Gap $class, \PHPNative\Tailwind\Style\Gap $style)
{
if ($style->x !== 0) {
$class->x = $style->x;
}
if ($style->y !== 0) {
$class->y = $style->y;
}
}
}

View File

@ -13,7 +13,7 @@ class Padding implements Parser
$t = null;
$b = null;
preg_match_all('/p-(\d*)/', $style, $output_array);
preg_match_all('/\bp-(\d*)\b/', $style, $output_array);
if (count($output_array[0]) > 0) {
// Tailwind uses a 4px scale
$l = (int)$output_array[1][0] * 4;
@ -22,34 +22,34 @@ class Padding implements Parser
$b = (int)$output_array[1][0] * 4;
}
preg_match_all('/px-(\d*)/', $style, $output_array);
preg_match_all('/\bpx-(\d*)\b/', $style, $output_array);
if (count($output_array[0]) > 0) {
$l = (int)$output_array[1][0] * 4;
$r = (int)$output_array[1][0] * 4;
}
preg_match_all('/py-(\d*)/', $style, $output_array);
preg_match_all('/\bpy-(\d*)\b/', $style, $output_array);
if (count($output_array[0]) > 0) {
$t = (int)$output_array[1][0] * 4;
$b = (int)$output_array[1][0] * 4;
}
preg_match_all('/pt-(\d*)/', $style, $output_array);
preg_match_all('/\bpt-(\d*)\b/', $style, $output_array);
if (count($output_array[0]) > 0) {
$t = (int)$output_array[1][0] * 4;
}
preg_match_all('/pb-(\d*)/', $style, $output_array);
preg_match_all('/\bpb-(\d*)\b/', $style, $output_array);
if (count($output_array[0]) > 0) {
$b = (int)$output_array[1][0] * 4;
}
preg_match_all('/pl-(\d*)/', $style, $output_array);
preg_match_all('/\bpl-(\d*)\b/', $style, $output_array);
if (count($output_array[0]) > 0) {
$l = (int)$output_array[1][0] * 4;
}
preg_match_all('/pr-(\d*)/', $style, $output_array);
preg_match_all('/\bpr-(\d*)\b/', $style, $output_array);
if (count($output_array[0]) > 0) {
$r = (int)$output_array[1][0] * 4;
}

View File

@ -0,0 +1,50 @@
<?php
namespace PHPNative\Tailwind\Style;
class Gap implements Style
{
public int $x;
public int $y;
public function __construct(int $x = 0, int $y = 0)
{
$this->x = $x;
$this->y = $y;
}
public static function parse(string $class): null|self
{
// gap-{n} - both x and y
if (preg_match('/^gap-(\d+)$/', $class, $matches)) {
$value = (int) $matches[1] * 4; // Tailwind uses 4px units
return new self($value, $value);
}
// gap-x-{n}
if (preg_match('/^gap-x-(\d+)$/', $class, $matches)) {
$value = (int) $matches[1] * 4;
return new self($value, 0);
}
// gap-y-{n}
if (preg_match('/^gap-y-(\d+)$/', $class, $matches)) {
$value = (int) $matches[1] * 4;
return new self(0, $value);
}
return null;
}
public function merge(Style $other): Style
{
if (!($other instanceof self)) {
return $this;
}
return new self(
$other->x !== 0 ? $other->x : $this->x,
$other->y !== 0 ? $other->y : $this->y,
);
}
}

View File

@ -69,6 +69,9 @@ class StyleParser
if($m = \PHPNative\Tailwind\Parser\Margin::parse($style)) {
return $m;
}
if($g = \PHPNative\Tailwind\Parser\Gap::parse($style)) {
return $g;
}
if($o = \PHPNative\Tailwind\Parser\Overflow::parse($style)) {
return $o;
}

View File

@ -402,7 +402,10 @@ abstract class Component
$this->markDirty(false, false);
}
foreach ($this->children as $child) {
$child->handleMouseMove($mouseX, $mouseY);
// Skip overlays - they are handled separately by the Window
if (!$child->isOverlay()) {
$child->handleMouseMove($mouseX, $mouseY);
}
}
}

View File

@ -183,10 +183,34 @@ class Container extends Component
$isRow = $flex->direction === DirectionEnum::row;
$availableSpace = $isRow ? $this->contentViewport->width : $this->contentViewport->height;
// Get gap from styles
$gap = $this->computedStyles[\PHPNative\Tailwind\Style\Gap::class] ?? null;
$gapSize = 0;
if ($gap) {
$gapSize = $isRow ? $gap->x : $gap->y;
// Debug output
if ($gapSize > 0) {
error_log("Container gap detected: " . $gapSize . "px (" . ($isRow ? "row" : "col") . ")");
}
}
// First pass: calculate fixed sizes and count flex-grow items
$childSizes = [];
$flexGrowCount = 0;
$usedSpace = 0;
$nonOverlayCount = 0;
// Count non-overlay children first
foreach ($this->children as $child) {
if (!$child->isOverlay()) {
$nonOverlayCount++;
}
}
// Add gap space to used space (n-1 gaps for n children)
if ($nonOverlayCount > 1 && $gapSize > 0) {
$usedSpace += ($nonOverlayCount - 1) * $gapSize;
}
foreach ($this->children as $index => $child) {
// Skip overlays in flex layout
@ -254,6 +278,7 @@ class Container extends Component
// Second pass: assign sizes and position children
$currentPosition = $isRow ? $this->contentViewport->x : $this->contentViewport->y;
$childIndex = 0;
foreach ($this->children as $index => $child) {
// Skip overlays in flex layout
@ -264,6 +289,11 @@ class Container extends Component
$childSize = $childSizes[$index];
$size = $childSize['flexGrow'] ? $flexGrowSize : $childSize['size'];
// Add gap before this child (except for first child)
if ($childIndex > 0 && $gapSize > 0) {
$currentPosition += $gapSize;
}
// Create viewport for child
if ($isRow) {
// Flex row
@ -291,6 +321,7 @@ class Container extends Component
$child->setViewport($childViewport);
$child->layout($textRenderer);
$childIndex++;
}
}

View File

@ -131,6 +131,21 @@ class Modal extends Container
$this->markDirty(false);
}
public function setVisible(bool $visible): void
{
$wasVisible = $this->isVisible();
parent::setVisible($visible);
// When modal becomes visible, clear hover states on background components
if ($visible && !$wasVisible && $this->attachedWindow) {
// Send a mouse move event outside the window to clear all hover states
$rootComponent = $this->attachedWindow->getRoot();
if ($rootComponent) {
$rootComponent->handleMouseMove(-1000, -1000);
}
}
}
public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool
{
if (!$this->isVisible()) {
@ -147,7 +162,10 @@ class Modal extends Container
return;
}
// Always handle mouse move in modal to prevent background hover states
parent::handleMouseMove($mouseX, $mouseY);
// Don't propagate to components below the modal
}
public function handleMouseWheel(float $mouseX, float $mouseY, float $deltaY): bool

View File

@ -222,9 +222,27 @@ class Window
$this->mouseX = $event['x'] ?? 0;
$this->mouseY = $event['y'] ?? 0;
// Propagate mouse move to root component
// Check overlays first (in reverse z-index order - highest first)
if ($this->rootComponent) {
$this->rootComponent->handleMouseMove($this->mouseX, $this->mouseY);
$overlays = $this->rootComponent->collectOverlays();
usort($overlays, fn($a, $b) => $b->getZIndex() <=> $a->getZIndex());
$handled = false;
foreach ($overlays as $overlay) {
if ($overlay->isVisible()) {
$overlay->handleMouseMove($this->mouseX, $this->mouseY);
$handled = true;
break;
}
}
// If overlay is visible, send fake event to background to clear hover states
if ($handled) {
$this->rootComponent->handleMouseMove(-1000, -1000);
} else {
// If no overlay handled it, propagate to normal components
$this->rootComponent->handleMouseMove($this->mouseX, $this->mouseY);
}
}
break;