diff --git a/CLAUDE.md b/CLAUDE.md index c64c7ad..3b480e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,51 +4,77 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -PHP GUI library providing cross-platform desktop GUI development using Tcl/Tk via PHP's FFI extension. Requires PHP 8.1+ with `ext-ffi`. +PHP GUI library for cross-platform desktop apps. Two rendering modes share one event loop: + +- **Native widgets** — Tcl/Tk loaded via PHP FFI (forms, dialogs, system controls). +- **WebView** — HTML/CSS/JS frontend driven by a bundled `webview_helper` subprocess (Tauri-like, with a JS↔PHP bridge). + +Requires PHP 8.1+ with `ext-ffi` (`ffi.enable=true` in `php.ini`). Native libraries for both modes are bundled under `src/lib/` — no system packages required on Linux/macOS/Windows (Linux WebView still needs `libwebkit2gtk-4.1-dev`). ## Commands ```bash -# Install dependencies -composer install - -# Run the example app -php example.php +composer install # install (no runtime deps; sets up autoload) +php example.php # run example app -# Run tests (no test framework — plain PHP scripts) -php tests/index_test.php -php tests/widgets_test/WindowTest.php +# Tests — plain PHP scripts, no PHPUnit. Each script exits 1 on failure. +php tests/index_test.php # integration smoke test +php tests/widgets_test/WindowTest.php # single widget suite +php tests/webview/WebViewWidgetTest.php # webview suite (needs helper binary) ``` -There is no PHPUnit, linter, or build system configured. +There is no linter, build system, or PHPUnit. Tests use the in-repo `tests/TestRunner.php` (suite + `assert*` helpers, summary on exit). ## Architecture -**FFI Bridge Pattern**: PHP classes → `ProcessTCL` (FFI singleton) → native Tcl/Tk C library. +### Two processes, one event loop + +``` +PHP process ──FFI──▶ libtcl/libtk (native widgets, in-process) + │ + └──proc_open──▶ webview_helper (separate native window, JSON-over-stdio IPC) +``` + +`Application::run()` is the single event loop driving both: it calls Tcl `update`, polls callback temp files, and pumps stdin/stdout for any registered WebView helpers. + +### Native-widget path + +- **`ProcessTCL`** (`src/ProcessTCL.php`) — FFI singleton. Loads the platform-specific Tcl shared library from `src/lib/`, executes Tcl commands, and owns the callback registry (unique ID → PHP closure). +- **`AbstractWidget`** (`src/Widget/AbstractWidget.php`) — base for all Tcl/Tk widgets. Generates IDs via `uniqid()`, manages parent paths, exposes `pack()` / `grid()` / `place()`, converts PHP option arrays into Tcl option strings. +- **Widget hierarchy** — `Window` and `TopLevel` are root (null parent). All others (`Button`, `Label`, `Input`/`Entry`, `Frame`, `Menu`, `Canvas`, `Checkbutton`, `Combobox`, `Menubutton`, `Message`, `Image`) require a parent widget ID. `Input` wraps `Entry`. + +#### Tcl callback bridge (the central pattern) -### Core Components +When a Tcl event fires, the bound Tcl command writes the callback ID to `/tmp/phpgui_callback.txt`. The event loop tails this file, looks up the ID in `ProcessTCL`'s registry, and invokes the PHP closure. A second temp file (`/tmp/phpgui_quit.txt`) signals quit. **Every interactive widget (Button `command`, Input `onEnter`, Menu commands, etc.) goes through this file-based bridge** — when adding a new event type, follow the same pattern and register via `ProcessTCL`. -- **`ProcessTCL`** — Singleton that loads the native Tcl library via FFI and executes Tcl commands. Handles platform-specific library paths (`.so`, `.dll`, `.dylib`). Manages a callback registry mapping unique IDs to PHP closures. +### WebView path -- **`Application`** — Event loop. Initializes Tcl/Tk, then continuously calls `update` while polling temp files for callback triggers (`/tmp/phpgui_callback.txt`) and quit signals (`/tmp/phpgui_quit.txt`). +- **`ProcessWebView`** (`src/ProcessWebView.php`) — analogous to `ProcessTCL`, but spawns the `webview_helper` binary via `proc_open()` and speaks newline-delimited JSON on stdin/stdout. Non-blocking reads buffered through a single read buffer. +- **`Widget\WebView`** (`src/Widget/WebView.php`) — does **not** extend `AbstractWidget` (it owns a separate OS window, not a Tcl/Tk widget). Registered with `Application::addWebView()` so the event loop pumps it. +- **Helper binary** — prebuilt per platform under `src/lib/`: `webview_helper_linux_x86_64`, `webview_helper` (macOS), and a Windows variant. Wraps WebKitGTK / WKWebView / WebView2 with a uniform JSON protocol. Installed/copied by `src/Install/LibraryInstaller.php` and `scripts/install-webview-helper.php`. +- **JS↔PHP bridge** — `WebView::bind($name, $cb)` exposes PHP to JS as `invoke(name, ...args)`; `WebView::emit($event, $data)` pushes events to the page (`onPhpEvent(name, cb)` on the JS side). Both flow over the same JSON IPC channel. +- **Frontend serving** — `serveFromDisk($dir)` registers a custom URI scheme per platform (`phpgui://` on Linux, `https://phpgui.localhost/` via virtual host on Windows, `loadFileURL:allowingReadAccess:` on macOS). `serveVite($distDir)` auto-detects a running Vite dev server (HMR) vs. production build. `enableFetchProxy()` routes `fetch()` calls through PHP to bypass CORS on `phpgui://`/`file://` origins — must be called **before** `serveFromDisk()` / `serveVite()`. -- **`AbstractWidget`** — Base class for all widgets. Assigns unique IDs via `uniqid()`, manages parent-child relationships, provides layout methods (`pack()`, `grid()`, `place()`), and converts PHP option arrays to Tcl option strings. +### Native libraries (`src/lib/`) -### Event Handling +| Platform | Tcl/Tk | WebView helper | +|---|---|---| +| Linux x86-64 | `libtcl8.6.so`, `libtk8.6.so` | `webview_helper_linux_x86_64` | +| macOS | `libtcl9.0.dylib`, `libtk9.0.dylib`, `libtommath.1.dylib` | `webview_helper` | +| Windows | `windows/bin/tcl86t.dll` | (under `windows/`) | -Callbacks use a temp-file bridge: when a Tcl event fires, it writes a callback ID to `/tmp/phpgui_callback.txt`. The `Application` event loop detects this, looks up the registered PHP closure, and executes it. This is the central pattern — all interactive widgets (Button, Input, Menu) use this mechanism. +Linux Tcl libs are rebuilt against an older glibc via `build/rebuild-linux-libs.dockerfile` to stay compatible with glibc 2.34+. Don't replace these by-hand — use the dockerfile. -### Widget Hierarchy +### Namespace & autoload -All widgets extend `AbstractWidget`. `Window` and `TopLevel` are root-level (null parent). All others (Button, Label, Input, Frame, Menu, Canvas, etc.) require a parent widget. `Input` is an alias/wrapper around `Entry`. +PSR-4: `PhpGui\` → `src/`. Widgets under `PhpGui\Widget\`. Test helper under `PhpGuiTest\` → `tests/`. -### Native Libraries +## Tests -Pre-compiled Tcl libraries are bundled in `src/lib/`: -- Linux: `libtcl8.6.so` -- macOS: `libtcl9.0.dylib` -- Windows: `windows/bin/tcl86t.dll` +Each test file is standalone: `require` `vendor/autoload.php` and `TestRunner.php`, call `TestRunner::suite('Name')`, run assertions, end with `TestRunner::summary()` (which `exit(1)`s on any failure). -### Namespace & Autoloading +Common assertions: +- `TestRunner::assert(bool, msg)` / `assertEqual(expected, actual, msg)` +- `TestRunner::assertWidgetExists(".widgetPath", msg)` — checks via Tcl `winfo exists` -PSR-4: `PhpGui\` → `src/`. Widgets are under `PhpGui\Widget\`. +Callbacks can be triggered without a real GUI event by calling `ProcessTCL::getInstance()->executeCallback($widgetId)` directly (see `tests/index_test.php` for the pattern). WebView tests under `tests/webview/` exercise the helper subprocess and IPC; they require the helper binary to be present. diff --git a/README.md b/README.md index 4a430a9..efe00a1 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ Native widgets render as real OS controls using Tcl/Tk under the hood. The PHP A | `Menubutton` | Standalone menu button | [→](docs/Menubutton.md) | | `Canvas` | Drawing surface for shapes and images | [→](docs/Canvas.md) | | `Message` | Multi-line text display | [→](docs/Message.md) | -| `Image` | Display images inside windows | — | +| `Image` | Display images inside windows | [→](docs/Image.md) | ### Layout diff --git a/assets/54396379.png b/assets/54396379.png new file mode 100644 index 0000000..ef3cff0 Binary files /dev/null and b/assets/54396379.png differ diff --git a/assets/example.png b/assets/example.png new file mode 100644 index 0000000..f07042a Binary files /dev/null and b/assets/example.png differ diff --git a/assets/happy-cat.gif b/assets/happy-cat.gif new file mode 100644 index 0000000..6c8fff7 Binary files /dev/null and b/assets/happy-cat.gif differ diff --git a/docs/Image.md b/docs/Image.md new file mode 100644 index 0000000..ac97dc5 --- /dev/null +++ b/docs/Image.md @@ -0,0 +1,121 @@ +# Image Widget + +The **Image** widget displays an image inside a parent window. Internally it is a Tk `label` whose `-image` is a Tk photo image loaded from disk, so it supports the same layout managers as any other widget (`pack`, `place`, `grid`). + +--- + +### Constructor + +```php +new Image(string $parentId, array $options) +``` + +| Parameter | Type | Description | +|-------------|----------|----------------------------------------------| +| `$parentId` | `string` | `getId()` of the parent widget. | +| `$options` | `array` | Configuration options — see table below. `path` is required. | + +Throws `InvalidArgumentException` if `path` is missing, and `RuntimeException` if the file does not exist or its extension is not a supported image format. + +--- + +### Options + +| Key | Type | Description | +|----------|----------|---------------------------------------------------------------------| +| `path` | `string` | **Required.** Filesystem path to the image file. | +| `bg` | `string` | Background color shown around the image. | +| `relief` | `string` | Border style: `flat`, `raised`, `sunken`, `groove`, `ridge`. | +| `padx` | `int` | Horizontal internal padding. | +| `pady` | `int` | Vertical internal padding. | +| `cursor` | `string` | Cursor shown when hovering the image. | + +Any other key is forwarded as a Tk `-key value` pair on the underlying label. + +--- + +### Supported formats + +| Format | How | +|-------------------|---------------------------------------------------------------------------------------| +| PNG, GIF, PPM/PGM | Loaded directly by Tk's `image create photo`. | +| JPEG, BMP | Transparently transcoded to a temp PNG via PHP's GD extension before being given to Tk. | + +JPEG and BMP support requires `ext-gd` to be enabled (the default in most PHP builds). The transcoded PNG lives in `sys_get_temp_dir()` for as long as the widget exists and is unlinked automatically by `setPath()` and `destroy()`. + +--- + +### Animated GIFs + +Multi-frame GIFs play automatically. The widget parses the GIF for per-frame delays from the Graphic Control Extension blocks, then drives a Tcl `after`-based loop that swaps the photo's `-format "gif -index N"` on each tick. The loop runs entirely inside Tk's event loop (which `Application::run()` already pumps), so there is no PHP round-trip per frame. + +```php +$loader = new Image($window->getId(), ['path' => 'assets/spinner.gif']); +$loader->pack(); + +$loader->isAnimated(); // true +$loader->getFrameCount(); // e.g. 12 +``` + +The animation is cancelled automatically on `destroy()`, and on `setPath()` to either a non-GIF or a different GIF (a fresh loop is started for the new file). Frame delays under 20ms are clamped to 100ms to avoid busy-loops on GIFs that encode "0" to mean "as fast as possible". + +--- + +### Examples + +**Display a PNG:** +```php +use PhpGui\Widget\Image; + +$logo = new Image($window->getId(), ['path' => __DIR__ . '/assets/logo.png']); +$logo->pack(['pady' => 20]); +``` + +**Swap the image at runtime:** +```php +$photo = new Image($window->getId(), ['path' => 'avatars/default.png']); +$photo->pack(); + +$btn = new Button($window->getId(), [ + 'text' => 'Load avatar', + 'command' => fn() => $photo->setPath('avatars/user-42.png'), +]); +$btn->pack(); +``` + +**Add a frame and padding:** +```php +$image = new Image($window->getId(), [ + 'path' => 'screenshot.png', + 'relief' => 'sunken', + 'padx' => 8, + 'pady' => 8, + 'bg' => '#222', +]); +$image->pack(['padx' => 12, 'pady' => 12]); +``` + +--- + +### Methods + +| Method | Signature | Description | +|---------------|----------------------------|-----------------------------------------------------------------------------| +| `setPath()` | `(string $path): void` | Reloads pixels from a new file. Cancels any running animation and starts a new one if the file is an animated GIF. | +| `getPath()` | `(): string` | Returns the currently loaded image path (with normalized separators). | +| `getWidth()` | `(): int` | Width of the loaded image in pixels (`image width`). | +| `getHeight()` | `(): int` | Height of the loaded image in pixels (`image height`). | +| `getFrameCount()` | `(): int` | Total frames in the loaded image (1 for non-animated). | +| `isAnimated()` | `(): bool` | True when an animation loop is scheduled for this image. | +| `pack()` | `(array $opts = []): void` | Inherited. Pack layout manager. | +| `place()` | `(array $opts = []): void` | Inherited. Place layout manager. | +| `grid()` | `(array $opts = []): void` | Inherited. Grid layout manager. | +| `destroy()` | `(): void` | Removes the label **and** frees the underlying photo image from Tk's image table. | + +--- + +### Notes + +- Each `Image` instance owns its own Tk photo image (`phpgui_photo_`). Always call `destroy()` when you replace or discard the widget — photo images are not garbage-collected with their containing label. +- `setPath()` reuses the same photo image, so any other label currently bound to it will also update. +- Paths containing spaces, brackets, or `$` are handled safely; the path is passed via a Tcl variable rather than interpolated into the command. diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 5947c51..6504fd7 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -13,6 +13,7 @@ - [Entry](Entry.md) - [Frame](Frame.md) - [Canvas](Canvas.md) + - [Image](Image.md) - [Menu](Menu.md) - [Menubutton](Menubutton.md) - [Checkbutton](Checkbutton.md) diff --git a/example.php b/example.php index df856a5..fc5c596 100755 --- a/example.php +++ b/example.php @@ -1,226 +1,311 @@ - 'Hello World Example in php', - 'width' => 800, - 'height' => 600 -]); - -// Label Example -$label = new Label($window->getId(), [ - 'text' => 'Hello, PHP GUI World!' -]); -$label->pack(['pady' => 20]); - - -// Extra styled Button example -$styledButton = new Button($window->getId(), [ - 'text' => 'Styled Button', - 'command' => function () use ($label) { - echo "Styled Button clicked!\n"; - $label->setText('Styled Button clicked!'); - }, - 'bg' => 'blue', - 'fg' => 'white', - 'font' => 'Helvetica 16 bold' -]); -$styledButton->pack(['pady' => 10]); - -// New Input widget example with extra configuration -$input = new Input($window->getId(), [ - 'text' => 'Type here...', - 'bg' => 'lightyellow', - 'fg' => 'black', - 'font' => 'Arial 14' -]); -$input->pack(['pady' => 10]); - -// Register event listener for Enter key on the input widget -$input->onEnter(function () use ($input) { - echo "Input Enter Pressed: " . $input->getValue() . "\n"; -}); - - -$styledLabel = new Label($window->getId(), [ - 'text' => 'This is a styled label with custom colors', - 'fg' => 'white', - 'bg' => '#4CAF50', - 'font' => 'Arial 12', - 'padx' => 10, - 'pady' => 5, - 'relief' => 'raised' -]); -$styledLabel->pack(['pady' => 5]); - -// Dynamic Label update example -$dynamicLabel = new Label($window->getId(), [ - 'text' => 'Dynamic Lable', - 'font' => 'Arial 11 italic', - 'fg' => '#666666' -]); -$dynamicLabel->pack(['pady' => 5]); - -// Button to demonstrate label updates -$updateButton = new Button($window->getId(), [ - 'text' => 'Update Labels', - 'command' => function () use ($dynamicLabel, $styledLabel) { - $dynamicLabel->setText('Label text updated!'); - $dynamicLabel->setForeground('#009688'); - $styledLabel->setBackground('#2196F3'); - $styledLabel->setText('Colors and text can be changed dynamically'); - } -]); -$updateButton->pack(['pady' => 5]); - - -// Menu Examples -$mainMenu = new Menu($window->getId(), ['type' => 'main']); - -// File Menu -$fileMenu = $mainMenu->addSubmenu('File'); -$fileMenu->addCommand('New', function () use ($dynamicLabel) { - $dynamicLabel->setText('New File Selected'); -}); -$fileMenu->addCommand('Open', function () use ($dynamicLabel) { - $dynamicLabel->setText('Open Selected'); -}); -$fileMenu->addSeparator(); -$fileMenu->addCommand('Exit', function () { - exit(); -}, ['foreground' => 'red']); - -// Edit Menu -$editMenu = $mainMenu->addSubmenu('Edit'); -$editMenu->addCommand('Copy', function () use ($styledLabel) { - $styledLabel->setText('Copy Selected'); -}); -$editMenu->addCommand('Paste', function () use ($styledLabel) { - $styledLabel->setText('Paste Selected'); -}); - -// Help Menu with Nested Submenu -$helpMenu = $mainMenu->addSubmenu('Help'); -$aboutMenu = $helpMenu->addSubmenu('About'); -$aboutMenu->addCommand('Version', function () use ($dynamicLabel) { - $dynamicLabel->setText('Version 1.0'); -}); - -// TopLevel Examples -$topLevelButton = new Button($window->getId(), [ - 'text' => 'Open New Window', - 'command' => function () use ($dynamicLabel) { - $topLevel = new TopLevel([ - 'title' => 'New Window Example', - 'width' => 300, - 'height' => 200 - ]); - - // Add content to TopLevel - $label = new Label($topLevel->getId(), [ - 'text' => 'This is a new window', - 'font' => 'Arial 14' - ]); - $label->pack(['pady' => 20]); - - $closeBtn = new Button($topLevel->getId(), [ - 'text' => 'Close Window', - 'command' => function () use ($topLevel, $dynamicLabel) { - $dynamicLabel->setText('TopLevel window closed'); - $topLevel->destroy(); - } - ]); - $closeBtn->pack(['pady' => 10]); - - $minimizeBtn = new Button($topLevel->getId(), [ - 'text' => 'Minimize', - 'command' => function () use ($topLevel) { - $topLevel->iconify(); - } - ]); - $minimizeBtn->pack(['pady' => 10]); - - $dynamicLabel->setText('New window opened'); - } -]); -$topLevelButton->pack(['pady' => 10]); - -// Dialog Examples -$dialogsLabel = new Label($window->getId(), [ - 'text' => 'Dialog Examples:', - 'font' => 'Arial 12 bold' -]); -$dialogsLabel->pack(['pady' => 5]); - -// Color Picker Dialog -$colorButton = new Button($window->getId(), [ - 'text' => 'Choose Color', - 'command' => function () use ($dynamicLabel) { - try { - $color = TopLevel::chooseColor(); - if ($color) { - echo "Selected color: $color\n"; - $dynamicLabel->setText("Selected color: $color"); - $dynamicLabel->setForeground($color); - } - } catch (\Exception $e) { - echo "Error: " . $e->getMessage() . "\n"; - } - } -]); -$colorButton->pack(['pady' => 5]); - -// File Selection Dialog -$fileButton = new Button($window->getId(), [ - 'text' => 'Open File', - 'command' => function () use ($dynamicLabel) { - $file = TopLevel::getOpenFile(); - if ($file) { - $dynamicLabel->setText("Selected file: " . basename($file)); - } - } -]); -$fileButton->pack(['pady' => 5]); - -// Directory Selection Dialog -$dirButton = new Button($window->getId(), [ - 'text' => 'Choose Directory', - 'command' => function () use ($dynamicLabel) { - try { - $dir = TopLevel::chooseDirectory(); - if ($dir) { - echo "Selected directory: $dir\n"; - $dynamicLabel->setText("Selected directory: " . basename($dir)); - } - } catch (\Exception $e) { - echo "Error: " . $e->getMessage() . "\n"; - } - } -]); -$dirButton->pack(['pady' => 5]); - -// Message Box Example -$msgButton = new Button($window->getId(), [ - 'text' => 'Show Message', - 'command' => function () use ($dynamicLabel) { - $result = TopLevel::messageBox("This is a test message", "okcancel"); - $dynamicLabel->setText("Message result: $result"); - } -]); -$msgButton->pack(['pady' => 5]); - - -$app->run(); + 'PHP GUI — Widget Showcase', + 'width' => 920, + 'height' => 620, +]); +$wid = $window->getId(); +$tcl = ProcessTCL::getInstance(); + +// Layout uses three section Frames, each owning its own pack-stacked +// children. Frames are placed on the window via grid: +// +// row 0: title (columnspan 3) +// row 1: [inputs frame] [image frame] [dialogs frame] +// row 2: status bar (columnspan 3) +// +// Equal column weights with the `same` uniform group make all three +// section columns the same width so the sections sit balanced/centered. +// Row 1 also expands vertically so the sections occupy the body of the +// window rather than hugging the top. +foreach ([0, 1, 2] as $c) { + $tcl->evalTcl("grid columnconfigure {$window->getTclPath()} {$c} -weight 1 -uniform sections"); +} +$tcl->evalTcl("grid rowconfigure {$window->getTclPath()} 1 -weight 1"); + + +// ---------- Title ------------------------------------------------------------ + +$title = new Label($wid, [ + 'text' => 'PHP GUI — Widget Showcase', + 'font' => 'Helvetica 18 bold', + 'fg' => '#1976D2', +]); +$title->grid(['row' => 0, 'column' => 0, 'columnspan' => 3, 'pady' => 12]); + + +// ---------- Status bar (declared early so callbacks can capture it) ---------- + +$status = new Label($wid, [ + 'text' => 'Ready — interact with any widget to see updates here.', + 'font' => 'Arial 10 italic', + 'fg' => '#444', + 'bg' => '#f5f5f5', + 'relief' => 'sunken', + 'padx' => 10, + 'pady' => 6, +]); + + +// ---------- Helper: a section Frame with a bold header label inside ---------- + +$buildSection = function (string $headerText, int $col) use ($wid): Frame { + $frame = new Frame($wid); + // sticky 'n' (top, no horizontal stretch) keeps the frame compact and + // centered inside its equally-weighted grid column. + $frame->grid([ + 'row' => 1, + 'column' => $col, + 'sticky' => 'n', + 'padx' => 10, + 'pady' => 10, + ]); + + $header = new Label($frame->getId(), [ + 'text' => $headerText, + 'font' => 'Arial 12 bold', + 'fg' => '#333', + ]); + $header->pack(['pady' => 6]); + + return $frame; +}; + + +// ---------- Section: Buttons & Inputs ---------------------------------------- + +$inputs = $buildSection('Buttons & Inputs', 0); + +$styledButton = new Button($inputs->getId(), [ + 'text' => 'Styled Button', + 'bg' => '#1976D2', + 'fg' => 'white', + 'font' => 'Helvetica 12 bold', + 'command' => fn() => $status->setText('Styled Button clicked!'), +]); +$styledButton->pack(['pady' => 4, 'fill' => 'x', 'padx' => 12]); + +$input = new Input($inputs->getId(), [ + 'text' => 'Type and press Enter…', + 'bg' => 'lightyellow', + 'font' => 'Arial 12', +]); +$input->pack(['pady' => 4, 'fill' => 'x', 'padx' => 12]); +$input->onEnter(fn() => $status->setText('Input received: ' . $input->getValue())); + +$styledLabel = new Label($inputs->getId(), [ + 'text' => 'Styled label', + 'fg' => 'white', + 'bg' => '#4CAF50', + 'font' => 'Arial 11', + 'padx' => 10, + 'pady' => 5, + 'relief' => 'raised', +]); +$styledLabel->pack(['pady' => 4, 'fill' => 'x', 'padx' => 12]); + +$updateButton = new Button($inputs->getId(), [ + 'text' => 'Update Labels', + 'command' => function () use ($styledLabel, $status) { + $styledLabel->setBackground('#2196F3'); + $styledLabel->setText('Colors and text updated dynamically'); + $status->setText('Labels updated.'); + }, +]); +$updateButton->pack(['pady' => 4, 'fill' => 'x', 'padx' => 12]); + + +// ---------- Section: Image --------------------------------------------------- + +$imageSection = $buildSection('Image (animated GIF)', 1); + +$gifPath = __DIR__ . '/assets/happy-cat.gif'; +$jpgPath = __DIR__ . '/tests/widgets_test/image/example.jpg'; +$pngPath = __DIR__ . '/assets/example.png'; +$initialPath = is_file($gifPath) ? $gifPath : $pngPath; + +$logo = new Image($imageSection->getId(), [ + 'path' => $initialPath, + 'relief' => 'sunken', + 'padx' => 4, + 'pady' => 4, +]); +$logo->pack(['pady' => 6]); + +$logoInfo = new Label($imageSection->getId(), [ + 'text' => $logo->isAnimated() + ? sprintf('Animated — %d frames', $logo->getFrameCount()) + : 'Static image', + 'font' => 'Arial 10', + 'fg' => '#666', +]); +$logoInfo->pack(['pady' => 2]); + +$swapImageButton = new Button($imageSection->getId(), [ + 'text' => 'Swap to JPG', + 'command' => function () use ($logo, $logoInfo, $jpgPath, $status) { + if (!is_file($jpgPath)) { + $status->setText('Skipped — JPG fixture not found.'); + return; + } + $logo->setPath($jpgPath); + $logoInfo->setText('Static image (transcoded via GD)'); + $status->setText("Swapped to JPG — {$logo->getWidth()}x{$logo->getHeight()}"); + }, +]); +$swapImageButton->pack(['pady' => 4, 'fill' => 'x', 'padx' => 12]); + +$restoreImageButton = new Button($imageSection->getId(), [ + 'text' => 'Restore animated GIF', + 'command' => function () use ($logo, $logoInfo, $gifPath, $status) { + if (!is_file($gifPath)) { + $status->setText('Skipped — GIF asset not found.'); + return; + } + $logo->setPath($gifPath); + $logoInfo->setText(sprintf('Animated — %d frames', $logo->getFrameCount())); + $status->setText('Animation restored.'); + }, +]); +$restoreImageButton->pack(['pady' => 4, 'fill' => 'x', 'padx' => 12]); + + +// ---------- Section: Dialogs & Windows --------------------------------------- + +$dialogs = $buildSection('Dialogs & Windows', 2); + +$colorButton = new Button($dialogs->getId(), [ + 'text' => 'Choose Color', + 'command' => function () use ($status) { + try { + $color = TopLevel::chooseColor(); + if ($color) { + $status->setText("Selected color: {$color}"); + $status->setForeground($color); + } + } catch (\Exception $e) { + $status->setText('Error: ' . $e->getMessage()); + } + }, +]); +$colorButton->pack(['pady' => 4, 'fill' => 'x', 'padx' => 12]); + +$fileButton = new Button($dialogs->getId(), [ + 'text' => 'Open File', + 'command' => function () use ($status) { + $file = TopLevel::getOpenFile(); + if ($file) { + $status->setText('Selected file: ' . basename($file)); + } + }, +]); +$fileButton->pack(['pady' => 4, 'fill' => 'x', 'padx' => 12]); + +$dirButton = new Button($dialogs->getId(), [ + 'text' => 'Choose Directory', + 'command' => function () use ($status) { + try { + $dir = TopLevel::chooseDirectory(); + if ($dir) { + $status->setText('Selected directory: ' . basename($dir)); + } + } catch (\Exception $e) { + $status->setText('Error: ' . $e->getMessage()); + } + }, +]); +$dirButton->pack(['pady' => 4, 'fill' => 'x', 'padx' => 12]); + +$msgButton = new Button($dialogs->getId(), [ + 'text' => 'Show Message', + 'command' => function () use ($status) { + $result = TopLevel::messageBox('This is a test message', 'okcancel'); + $status->setText("Message result: {$result}"); + }, +]); +$msgButton->pack(['pady' => 4, 'fill' => 'x', 'padx' => 12]); + +$topLevelButton = new Button($dialogs->getId(), [ + 'text' => 'Open New Window', + 'command' => function () use ($status) { + $top = new TopLevel([ + 'title' => 'Secondary Window', + 'width' => 320, + 'height' => 180, + ]); + + $msg = new Label($top->getId(), [ + 'text' => 'This is a separate window.', + 'font' => 'Arial 12', + ]); + $msg->pack(['pady' => 20]); + + $closeBtn = new Button($top->getId(), [ + 'text' => 'Close', + 'command' => function () use ($top, $status) { + $top->destroy(); + $status->setText('Secondary window closed.'); + }, + ]); + $closeBtn->pack(['pady' => 8]); + + $minBtn = new Button($top->getId(), [ + 'text' => 'Minimize', + 'command' => fn() => $top->iconify(), + ]); + $minBtn->pack(['pady' => 4]); + + $status->setText('Secondary window opened.'); + }, +]); +$topLevelButton->pack(['pady' => 4, 'fill' => 'x', 'padx' => 12]); + + +// ---------- Status row at the bottom ----------------------------------------- + +$status->grid([ + 'row' => 2, + 'column' => 0, + 'columnspan' => 3, + 'sticky' => 'ew', + 'padx' => 10, + 'pady' => 10, +]); + + +// ---------- Menu bar --------------------------------------------------------- + +$menu = new Menu($wid, ['type' => 'main']); + +$fileMenu = $menu->addSubmenu('File'); +$fileMenu->addCommand('New', fn() => $status->setText('Menu: File → New')); +$fileMenu->addCommand('Open', fn() => $status->setText('Menu: File → Open')); +$fileMenu->addSeparator(); +$fileMenu->addCommand('Exit', fn() => exit(), ['foreground' => 'red']); + +$editMenu = $menu->addSubmenu('Edit'); +$editMenu->addCommand('Copy', fn() => $status->setText('Menu: Edit → Copy')); +$editMenu->addCommand('Paste', fn() => $status->setText('Menu: Edit → Paste')); + +$helpMenu = $menu->addSubmenu('Help'); +$aboutMenu = $helpMenu->addSubmenu('About'); +$aboutMenu->addCommand('Version', fn() => $status->setText('php-gui — version 1.0')); + + +$app->run(); diff --git a/src/Widget/AbstractWidget.php b/src/Widget/AbstractWidget.php index 5d2bf6d..b4882e1 100755 --- a/src/Widget/AbstractWidget.php +++ b/src/Widget/AbstractWidget.php @@ -1,139 +1,130 @@ -tcl = ProcessTCL::getInstance(); - $this->id = uniqid('w'); - if ($parentId !== null) { - $this->parentId = ltrim($parentId, '.'); - } else { - $this->parentId = null; - } - $this->options = $options; - } - - /** - * Creates the widget. - * - * Must be implemented by subclasses to define widget-specific creation logic. - * - * @return void - */ - abstract protected function create(): void; - - /** - * Applies the pack layout to the widget. - * - * @param array $options Options for the pack geometry manager. - * @throws \RuntimeException if the widget is top-level. - * @return void - */ - public function pack(array $options = []): void { - if ($this->parentId === null) { - throw new \RuntimeException("Cannot pack a top-level widget."); - } - $parent = '.' . $this->parentId; - $packCmd = "pack {$parent}.{$this->id}"; - if (!empty($options)) { - $packCmd .= ' ' . $this->formatOptions($options); - } - $this->tcl->evalTcl($packCmd); - } - - /** - * Positions the widget absolutely using the place geometry manager. - * - * @param array $options Options for absolute positioning. - * @throws \RuntimeException if the widget is top-level. - * @return void - */ - public function place(array $options = []): void { - if ($this->parentId === null) { - throw new \RuntimeException("Cannot place a top-level widget."); - } - $parent = '.' . $this->parentId; - $placeCmd = "place {$parent}.{$this->id}"; - if (!empty($options)) { - $placeCmd .= ' ' . $this->formatOptions($options); - } - $this->tcl->evalTcl($placeCmd); - } - - /** - * Positions the widget using the grid geometry manager. - * - * @param array $options Options for the grid geometry manager. - * @throws \RuntimeException if the widget is top-level. - * @return void - */ - public function grid(array $options = []): void { - if ($this->parentId === null) { - throw new \RuntimeException("Cannot grid a top-level widget."); - } - $parent = '.' . $this->parentId; - $gridCmd = "grid {$parent}.{$this->id}"; - if (!empty($options)) { - $gridCmd .= ' ' . $this->formatOptions($options); - } - $this->tcl->evalTcl($gridCmd); - } - - /** - * Destroys the widget. - * - * Removes the widget from the Tcl interpreter. - * - * @return void - */ - public function destroy(): void { - $parent = '.' . $this->parentId; - $this->tcl->evalTcl("destroy {$parent}.{$this->id}"); - } - - /** - * Returns the widget's unique identifier. - * - * @return string The widget ID. - */ - public function getId(): string { - return $this->id; - } - - /** - * Formats layout options into a Tcl-compatible string. - * - * @param array $options Key-value pairs of options. - * @return string A space-separated string of formatted options. - */ - protected function formatOptions(array $options): string { - $formatted = []; - foreach ($options as $key => $value) { - $formatted[] = "-$key $value"; - } - return implode(' ', $formatted); - } -} + id → widget instance, used to resolve parent paths. */ + private static array $registry = []; + + protected ProcessTCL $tcl; + protected string $id; + + /** Bare ID of the parent widget, or null for top-level widgets. Kept for back-compat. */ + protected ?string $parentId; + + /** Full Tcl path of the parent (e.g. `.w123.w456`). Empty string for top-level widgets. */ + protected string $parentTclPath; + + /** Full Tcl path of this widget (e.g. `.w123.w456.w789`). */ + protected string $tclPath; + + protected array $options; + + public function __construct(?string $parentId, array $options = []) + { + $this->tcl = ProcessTCL::getInstance(); + $this->id = uniqid('w'); + $this->options = $options; + + if ($parentId === null) { + $this->parentId = null; + $this->parentTclPath = ''; + $this->tclPath = '.' . $this->id; + } else { + $this->parentId = ltrim($parentId, '.'); + // Look up the parent in the registry to get its full Tcl path. + // If the parent isn't registered (e.g. a synthetic ID passed by + // older callers), fall back to treating the ID as a single-level + // path so existing single-nesting code keeps working. + $parent = self::$registry[$this->parentId] ?? null; + $this->parentTclPath = $parent !== null + ? $parent->tclPath + : '.' . $this->parentId; + $this->tclPath = $this->parentTclPath . '.' . $this->id; + } + + self::$registry[$this->id] = $this; + } + + abstract protected function create(): void; + + public function pack(array $options = []): void + { + $this->requireNonTopLevel('pack'); + $cmd = "pack {$this->tclPath}"; + if (!empty($options)) { + $cmd .= ' ' . $this->formatOptions($options); + } + $this->tcl->evalTcl($cmd); + } + + public function place(array $options = []): void + { + $this->requireNonTopLevel('place'); + $cmd = "place {$this->tclPath}"; + if (!empty($options)) { + $cmd .= ' ' . $this->formatOptions($options); + } + $this->tcl->evalTcl($cmd); + } + + public function grid(array $options = []): void + { + $this->requireNonTopLevel('grid'); + $cmd = "grid {$this->tclPath}"; + if (!empty($options)) { + $cmd .= ' ' . $this->formatOptions($options); + } + $this->tcl->evalTcl($cmd); + } + + public function destroy(): void + { + $this->tcl->evalTcl("destroy {$this->tclPath}"); + unset(self::$registry[$this->id]); + } + + /** Bare unique ID (used as a child's `$parentId` argument). */ + public function getId(): string + { + return $this->id; + } + + /** Full Tcl widget path, e.g. `.w123.w456.w789`. */ + public function getTclPath(): string + { + return $this->tclPath; + } + + protected function formatOptions(array $options): string + { + $formatted = []; + foreach ($options as $key => $value) { + $formatted[] = "-$key $value"; + } + return implode(' ', $formatted); + } + + private function requireNonTopLevel(string $manager): void + { + if ($this->parentId === null) { + throw new \RuntimeException("Cannot {$manager} a top-level widget."); + } + } +} diff --git a/src/Widget/Button.php b/src/Widget/Button.php index 9923117..f2f29d0 100755 --- a/src/Widget/Button.php +++ b/src/Widget/Button.php @@ -31,9 +31,9 @@ protected function create(): void call_user_func($this->callback); $this->tcl->evalTcl("update"); // Force widget updates }); - $this->tcl->evalTcl("button .{$this->parentId}.{$this->id} -text \"{$text}\" {$extra} -command {php::executeCallback {$this->id}}"); + $this->tcl->evalTcl("button {$this->tclPath} -text \"{$text}\" {$extra} -command {php::executeCallback {$this->id}}"); } else { - $this->tcl->evalTcl("button .{$this->parentId}.{$this->id} -text \"{$text}\" {$extra}"); + $this->tcl->evalTcl("button {$this->tclPath} -text \"{$text}\" {$extra}"); } } diff --git a/src/Widget/Canvas.php b/src/Widget/Canvas.php index 47c091a..c59f9aa 100644 --- a/src/Widget/Canvas.php +++ b/src/Widget/Canvas.php @@ -19,7 +19,7 @@ public function __construct(string $parentId, array $options = []) protected function create(): void { $extra = $this->getOptionString(); - $this->tcl->evalTcl("canvas .{$this->parentId}.{$this->id} {$extra}"); + $this->tcl->evalTcl("canvas {$this->tclPath} {$extra}"); } protected function getOptionString(): string @@ -34,35 +34,35 @@ protected function getOptionString(): string public function drawLine(int $x1, int $y1, int $x2, int $y2, array $options = []): string { $optStr = $this->formatOptions($options); - return (string) $this->tcl->evalTcl(".{$this->parentId}.{$this->id} create line $x1 $y1 $x2 $y2 $optStr"); + return (string) $this->tcl->evalTcl("{$this->tclPath} create line $x1 $y1 $x2 $y2 $optStr"); } public function drawRectangle(int $x1, int $y1, int $x2, int $y2, array $options = []): string { $optStr = $this->formatOptions($options); - return (string) $this->tcl->evalTcl(".{$this->parentId}.{$this->id} create rectangle $x1 $y1 $x2 $y2 $optStr"); + return (string) $this->tcl->evalTcl("{$this->tclPath} create rectangle $x1 $y1 $x2 $y2 $optStr"); } public function drawOval(int $x1, int $y1, int $x2, int $y2, array $options = []): string { $optStr = $this->formatOptions($options); - return (string) $this->tcl->evalTcl(".{$this->parentId}.{$this->id} create oval $x1 $y1 $x2 $y2 $optStr"); + return (string) $this->tcl->evalTcl("{$this->tclPath} create oval $x1 $y1 $x2 $y2 $optStr"); } public function drawText(int $x, int $y, string $text, array $options = []): string { $optStr = $this->formatOptions($options); - return (string) $this->tcl->evalTcl(".{$this->parentId}.{$this->id} create text $x $y -text {$text} $optStr"); + return (string) $this->tcl->evalTcl("{$this->tclPath} create text $x $y -text {$text} $optStr"); } public function delete(string $itemId): void { - $this->tcl->evalTcl(".{$this->parentId}.{$this->id} delete $itemId"); + $this->tcl->evalTcl("{$this->tclPath} delete $itemId"); } public function clear(): void { - $this->tcl->evalTcl(".{$this->parentId}.{$this->id} delete all"); + $this->tcl->evalTcl("{$this->tclPath} delete all"); } protected function formatOptions(array $options): string diff --git a/src/Widget/Checkbutton.php b/src/Widget/Checkbutton.php index 846375d..ec20e8c 100644 --- a/src/Widget/Checkbutton.php +++ b/src/Widget/Checkbutton.php @@ -27,9 +27,9 @@ protected function create(): void if ($this->callback) { ProcessTCL::getInstance()->registerCallback($this->id, $this->callback); - $this->tcl->evalTcl("checkbutton .{$this->parentId}.{$this->id} -text \"{$text}\" -variable {$this->variable} -command {php::executeCallback {$this->id}} {$extra}"); + $this->tcl->evalTcl("checkbutton {$this->tclPath} -text \"{$text}\" -variable {$this->variable} -command {php::executeCallback {$this->id}} {$extra}"); } else { - $this->tcl->evalTcl("checkbutton .{$this->parentId}.{$this->id} -text \"{$text}\" -variable {$this->variable} {$extra}"); + $this->tcl->evalTcl("checkbutton {$this->tclPath} -text \"{$text}\" -variable {$this->variable} {$extra}"); } } diff --git a/src/Widget/Combobox.php b/src/Widget/Combobox.php index 43bb602..f6584ac 100644 --- a/src/Widget/Combobox.php +++ b/src/Widget/Combobox.php @@ -18,7 +18,7 @@ protected function create(): void { $values = $this->options['values'] ?? ''; // Use -textvariable so getValue/setValue reflect the actual widget content. $this->tcl->evalTcl( - "ttk::combobox .{$this->parentId}.{$this->id} -textvariable {$this->id} -values {{$values}}" + "ttk::combobox {$this->tclPath} -textvariable {$this->id} -values {{$values}}" ); } diff --git a/src/Widget/Entry.php b/src/Widget/Entry.php index fe134a1..b5213e1 100644 --- a/src/Widget/Entry.php +++ b/src/Widget/Entry.php @@ -16,7 +16,7 @@ public function __construct(string $parentId, array $options = []) { protected function create(): void { $defaultText = $this->options['text'] ?? ''; // Use -textvariable so getValue/setValue reflect the actual widget content. - $this->tcl->evalTcl("entry .{$this->parentId}.{$this->id} -textvariable {$this->id}"); + $this->tcl->evalTcl("entry {$this->tclPath} -textvariable {$this->id}"); $this->tcl->evalTcl("set {$this->id} \"$defaultText\""); } diff --git a/src/Widget/Frame.php b/src/Widget/Frame.php index 7012785..c6669b1 100644 --- a/src/Widget/Frame.php +++ b/src/Widget/Frame.php @@ -14,6 +14,6 @@ public function __construct(string $parentId, array $options = []) { } protected function create(): void { - $this->tcl->evalTcl("frame .{$this->parentId}.{$this->id}"); + $this->tcl->evalTcl("frame {$this->tclPath}"); } } diff --git a/src/Widget/Image.php b/src/Widget/Image.php index cf32207..ac39b16 100644 --- a/src/Widget/Image.php +++ b/src/Widget/Image.php @@ -4,57 +4,544 @@ /** * Class Image - * Represents an image widget in the GUI. - * + * Displays an image inside a parent widget. Backed by a Tk `label` whose + * `-image` is a Tk photo image loaded from disk. + * + * Tk core's `image create photo` only accepts PNG, GIF, and PPM/PGM. JPEG + * and BMP files are transparently transcoded to a temporary PNG via PHP's + * GD extension. Animated GIFs cycle frames via a Tcl `after`-driven loop + * that runs inside the existing Tk event loop. + * * @package PhpGui\Widget */ class Image extends AbstractWidget { - private $imagePath; - private static $supportedFormats = ['png', 'jpg', 'jpeg', 'gif', 'bmp']; + /** Formats Tk's built-in photo image always supports. */ + private const CORE_FORMATS = ['png', 'gif', 'ppm', 'pgm']; + + /** Formats supported via on-the-fly transcoding through GD. */ + private const GD_FORMATS = ['jpg', 'jpeg', 'bmp']; + + /** Whether the global Tcl animation proc has been installed yet. */ + private static bool $animProcDefined = false; + + private string $imagePath; // user-facing original path + private string $loadedPath; // path actually fed to Tk (may be a temp PNG) + private string $photoName; + private ?string $tempFile = null; + + /** @var int Number of frames in the current image (>=1; 0 = not yet loaded). */ + private int $frameCount = 0; public function __construct(string $parentId, array $options = []) { if (!isset($options['path'])) { - throw new \InvalidArgumentException("Image path is required"); + throw new \InvalidArgumentException('Image path is required'); + } + + parent::__construct($parentId, $options); + $this->photoName = 'phpgui_photo_' . $this->id; + + [$this->imagePath, $this->loadedPath, $this->tempFile] = + $this->prepareImage($options['path']); + + $this->create(); + } + + protected function create(): void + { + $this->createPhotoFromPath($this->loadedPath, $this->photoName); + + $extra = $this->getOptionString(); + $this->tcl->evalTcl("label {$this->tclPath} -image {$this->photoName}{$extra}"); + + $this->maybeStartAnimation(); + } + + /** + * Replaces the displayed image with a new file on disk. The widget itself + * is not recreated — only the underlying photo image's pixels change. + * Any temp file from a previous transcode is removed; any running GIF + * animation is stopped before the new image is loaded. + */ + public function setPath(string $path): void + { + [$resolved, $loaded, $newTemp] = $this->prepareImage($path); + + $this->stopAnimation(); + + $this->tcl->setVar('phpgui_img_path', $loaded); + // -format {} clears any lingering "gif -index N" set by a previous + // animation loop, so Tk re-detects the format from the file header. + $this->tcl->evalTcl("{$this->photoName} configure -file \$phpgui_img_path -format {}"); + + $this->cleanupTempFile(); + + $this->imagePath = $resolved; + $this->loadedPath = $loaded; + $this->tempFile = $newTemp; + + $this->maybeStartAnimation(); + } + + public function getPath(): string + { + return $this->imagePath; + } + + public function getWidth(): int + { + return (int) trim($this->tcl->evalTcl("image width {$this->photoName}")); + } + + public function getHeight(): int + { + return (int) trim($this->tcl->evalTcl("image height {$this->photoName}")); + } + + /** Total number of frames in the loaded image. 1 for non-animated. */ + public function getFrameCount(): int + { + return $this->frameCount; + } + + /** True when an animation loop is scheduled for this image. */ + public function isAnimated(): bool + { + return $this->frameCount > 1; + } + + public function destroy(): void + { + $this->stopAnimation(); + parent::destroy(); + try { + $this->tcl->evalTcl("image delete {$this->photoName}"); + } catch (\Throwable) { + // Already gone; safe to ignore during shutdown paths. } + $this->cleanupTempFile(); + } - $this->imagePath = str_replace('\\', '/', $options['path']); + /** + * Resolve a user-supplied path to (originalPath, pathTkShouldLoad, tempFileOrNull). + * For PNG/GIF/PPM/PGM the three values are: original, original, null. + * For JPG/JPEG/BMP the third value is a temp PNG that the caller owns. + * + * @return array{0:string,1:string,2:?string} + */ + private function prepareImage(string $path): array + { + $normalized = str_replace('\\', '/', $path); - // Validate file exists - if (!file_exists($this->imagePath)) { - throw new \RuntimeException("Image file not found: {$options['path']}"); + if (!is_file($normalized)) { + throw new \RuntimeException("Image file not found: {$path}"); } - // Validate file extension - $extension = strtolower(pathinfo($this->imagePath, PATHINFO_EXTENSION)); - if (!in_array($extension, self::$supportedFormats)) { - throw new \RuntimeException("Unsupported image format: {$extension}. Supported formats: " . implode(', ', self::$supportedFormats)); + $extension = strtolower(pathinfo($normalized, PATHINFO_EXTENSION)); + + if (\in_array($extension, self::CORE_FORMATS, true)) { + return [$normalized, $normalized, null]; } - parent::__construct($parentId, $options); - $this->create(); + if (\in_array($extension, self::GD_FORMATS, true)) { + $temp = $this->transcodeToPng($normalized, $extension); + return [$normalized, $temp, $temp]; + } + + throw new \RuntimeException(\sprintf( + "Unsupported image format '%s'. Supported: %s", + $extension, + implode(', ', [...self::CORE_FORMATS, ...self::GD_FORMATS]) + )); } - protected function create(): void + /** + * Transcode a JPEG/BMP file to a temp PNG via GD so Tk can display it. + * The returned path lives in sys_get_temp_dir() and must be unlinked by + * the caller (we track it on the instance for that purpose). + */ + private function transcodeToPng(string $path, string $extension): string { - // Create unique photo image name - $photoName = "photo_" . $this->id; + if (!\function_exists('imagecreatefromstring')) { + throw new \RuntimeException( + "Loading '{$extension}' requires PHP's GD extension. " . + "Install php-gd or convert the image to PNG/GIF first." + ); + } - // Create photo image and load file - $this->tcl->evalTcl("image create photo $photoName -file {$this->imagePath}"); + $data = @file_get_contents($path); + if ($data === false) { + throw new \RuntimeException("Could not read image file: {$path}"); + } - // Create label to display the image with options - $extra = $this->getOptionString(); - $this->tcl->evalTcl("label .{$this->parentId}.{$this->id} -image $photoName {$extra}"); + $gd = @imagecreatefromstring($data); + if ($gd === false) { + throw new \RuntimeException( + "GD failed to decode '{$extension}' image: {$path}" + ); + } + + imagealphablending($gd, false); + imagesavealpha($gd, true); + + // tempnam() guarantees a unique, writable path under the system + // temp dir without worrying about a missing trailing separator or + // a uniqid collision. Tk's photo loader sniffs the format from the + // file header, so the lack of a `.png` suffix is fine. + $tempPath = tempnam(sys_get_temp_dir(), 'phpgui_img_'); + if ($tempPath === false) { + throw new \RuntimeException('Could not allocate a temp file for image transcode'); + } + + $ok = @imagepng($gd, $tempPath); + if (!$ok) { + @unlink($tempPath); + throw new \RuntimeException("Could not write transcoded PNG to {$tempPath}"); + } + + return $tempPath; + } + + private function cleanupTempFile(): void + { + if ($this->tempFile !== null && is_file($this->tempFile)) { + @unlink($this->tempFile); + } + $this->tempFile = null; + } + + /** + * Create the underlying photo image, routing the path through a Tcl + * variable so paths with spaces, brackets, `$`, or quotes are safe. + */ + private function createPhotoFromPath(string $path, string $photoName): void + { + $this->tcl->setVar('phpgui_img_path', $path); + $this->tcl->evalTcl("image create photo {$photoName} -file \$phpgui_img_path"); + } + + /** + * If the loaded file is a multi-frame GIF, composite every frame onto + * a logical-screen-sized canvas (honoring per-frame offsets, transparency, + * and disposal methods 1/2) and snapshot each composited result into + * its own photo image. Animation swaps the label's `-image` between + * those snapshots — no per-tick decoding, all frames are full-screen + * sized so the label never resizes, and transparent gaps never flash + * through the widget background. + */ + private function maybeStartAnimation(): void + { + $extension = strtolower(pathinfo($this->loadedPath, PATHINFO_EXTENSION)); + if ($extension !== 'gif') { + $this->frameCount = 1; + return; + } + + $info = self::parseGif($this->loadedPath); + $totalFrames = count($info['frames']); + $this->frameCount = max(1, $totalFrames); + + if ($totalFrames <= 1) { + return; + } + + $screenW = max(1, $info['screenW']); + $screenH = max(1, $info['screenH']); + $tmp = "phpgui_tmp_{$this->id}"; + + // Resize the main photo to the logical screen, then clear it. The + // label is already bound to it by name, so it'll reflect updates. + $this->tcl->evalTcl("{$this->photoName} configure -width {$screenW} -height {$screenH}"); + $this->tcl->evalTcl("{$this->photoName} blank"); + $this->tcl->setVar('phpgui_img_path', $this->loadedPath); + + $framePhotos = []; + $prev = null; + for ($i = 0; $i < $totalFrames; $i++) { + $f = $info['frames'][$i]; + + if ($prev !== null) { + $this->applyDisposal($prev); + } + + try { + $this->tcl->evalTcl( + "image create photo {$tmp} -file \$phpgui_img_path -format {gif -index {$i}}" + ); + } catch (\RuntimeException) { + // Couldn't decode this frame — keep what we have so far. + $this->frameCount = max(1, $i); + break; + } + + // Overlay rule keeps existing pixels where the source is + // transparent, so unchanged regions of prior frames remain. + $this->tcl->evalTcl( + "{$this->photoName} copy {$tmp} -to {$f['x']} {$f['y']} -compositingrule overlay" + ); + $this->tcl->evalTcl("image delete {$tmp}"); + + $framePhoto = "{$this->photoName}_frame{$i}"; + $this->tcl->evalTcl( + "image create photo {$framePhoto} -width {$screenW} -height {$screenH}" + ); + // -compositingrule set copies pixels verbatim, including alpha, + // so the snapshot freezes the canvas at this instant. + $this->tcl->evalTcl("{$framePhoto} copy {$this->photoName} -compositingrule set"); + $framePhotos[] = $framePhoto; + + $prev = $f; + } + + if (count($framePhotos) <= 1) { + // Salvage frame 0 if compositing collapsed (shouldn't happen). + $this->frameCount = 1; + return; + } + + // Reset main photo to frame 0 so the label currently shows the start. + $this->tcl->evalTcl("{$this->photoName} blank"); + $this->tcl->evalTcl("{$this->photoName} copy {$framePhotos[0]} -compositingrule set"); + + $this->ensureAnimationProc(); + + $widgetPath = $this->tclPath; + $delaysTcl = '[list ' . implode(' ', array_map(static fn(array $f) => (int) $f['delay'], $info['frames'])) . ']'; + $framesTcl = '[list ' . implode(' ', $framePhotos) . ']'; + + $this->tcl->evalTcl("set ::phpgui_anim_widget({$this->photoName}) {$widgetPath}"); + $this->tcl->evalTcl("set ::phpgui_anim_frames({$this->photoName}) {$framesTcl}"); + $this->tcl->evalTcl("set ::phpgui_anim_delays({$this->photoName}) {$delaysTcl}"); + + // Schedule frame 1; the label is already showing frame 0. + $this->tcl->evalTcl( + "phpgui_anim_step {$this->photoName} 1 {$this->frameCount}" + ); + } + + /** + * Apply a frame's disposal method to the main canvas before drawing + * the next frame on top. Supports the common cases: + * 1 (do not dispose) — no-op, leave canvas as-is. + * 2 (restore to background) — clear the frame's rectangle to transparent. + * 3 (restore to previous) — fall back to "do not dispose" since + * restoring to a snapshot is rarely used in real-world GIFs. + * + * @param array{x:int,y:int,w:int,h:int,disposal:int} $frame + */ + private function applyDisposal(array $frame): void + { + if ($frame['disposal'] !== 2) { + return; + } + $clear = "phpgui_clear_{$this->id}"; + // A freshly created photo is fully transparent. Copying it with the + // `set` rule overwrites the canvas region — including alpha — so + // the rectangle becomes transparent again. + $this->tcl->evalTcl( + "image create photo {$clear} -width {$frame['w']} -height {$frame['h']}" + ); + $this->tcl->evalTcl(sprintf( + '%s copy %s -from 0 0 %d %d -to %d %d %d %d -compositingrule set', + $this->photoName, + $clear, + $frame['w'], + $frame['h'], + $frame['x'], + $frame['y'], + $frame['x'] + $frame['w'], + $frame['y'] + $frame['h'] + )); + $this->tcl->evalTcl("image delete {$clear}"); + } + + private function stopAnimation(): void + { + $widgetPath = $this->tclPath; + try { + // Cancel the after, drop the per-instance Tcl state, and re-bind + // the label to the main photo *before* freeing the frame photos. + // Otherwise the label is left pointing at a photo we're about to + // delete, which Tk would render as a blank widget. + $this->tcl->evalTcl(<<photoName})]} { + after cancel \$::phpgui_anim_after({$this->photoName}) + unset ::phpgui_anim_after({$this->photoName}) + } + foreach _v {phpgui_anim_widget phpgui_anim_frames phpgui_anim_delays} { + if {[info exists ::\${_v}({$this->photoName})]} { + unset ::\${_v}({$this->photoName}) + } + } + if {[winfo exists {$widgetPath}]} { + catch {{$widgetPath} configure -image {$this->photoName}} + } +TCL + ); + for ($i = 0; $i < $this->frameCount; $i++) { + try { + $this->tcl->evalTcl("image delete {$this->photoName}_frame{$i}"); + } catch (\Throwable) { + // Already gone. + } + } + } catch (\Throwable) { + // Best-effort during teardown. + } + } + + /** + * Define the global Tcl proc that drives the animation loop. Idempotent — + * runs on the first animated GIF in the process and is a no-op afterwards. + * + * The proc swaps the label's -image option to the next pre-loaded frame + * photo, then reschedules itself via `after`. It bails out cleanly if the + * label or main photo have been destroyed between callbacks. + */ + private function ensureAnimationProc(): void + { + if (self::$animProcDefined) { + return; + } + $this->tcl->evalTcl(<<<'TCL' + proc phpgui_anim_step {photo frame total} { + if {![info exists ::phpgui_anim_widget($photo)]} { return } + set widget $::phpgui_anim_widget($photo) + set frames $::phpgui_anim_frames($photo) + set delays $::phpgui_anim_delays($photo) + if {![winfo exists $widget]} { return } + set framePhoto [lindex $frames $frame] + catch {$widget configure -image $framePhoto} + set next [expr {($frame + 1) % $total}] + set delay [lindex $delays $frame] + if {$delay < 20} { set delay 100 } + set ::phpgui_anim_after($photo) \ + [after $delay [list phpgui_anim_step $photo $next $total]] + } +TCL + ); + self::$animProcDefined = true; + } + + /** + * Minimal GIF89a/87a parser. Extracts: + * - the logical-screen size (where frames composite onto) + * - per-frame: offset (x, y), size (w, h), delay (ms), disposal method + * Frames without an explicit Graphic Control Extension inherit delay + * 100ms and disposal 1. Delays under 20ms are clamped to 100ms — many + * GIFs encode 0 to mean "as fast as possible", which would peg the CPU. + * + * @return array{screenW:int, screenH:int, frames:list} + */ + private static function parseGif(string $path): array + { + $empty = ['screenW' => 0, 'screenH' => 0, 'frames' => []]; + $data = @file_get_contents($path); + if ($data === false || strlen($data) < 13) { + return $empty; + } + + $sig = substr($data, 0, 6); + if ($sig !== 'GIF89a' && $sig !== 'GIF87a') { + return $empty; + } + + $len = strlen($data); + $i = 6; + $screenW = ord($data[$i]) | (ord($data[$i + 1]) << 8); + $screenH = ord($data[$i + 2]) | (ord($data[$i + 3]) << 8); + $packed = ord($data[$i + 4]); + $i += 7; + if ($packed & 0x80) { + $i += 3 * (1 << (($packed & 0x07) + 1)); + } + + $frames = []; + $pendingDelay = 100; + $pendingDisposal = 1; + + while ($i < $len) { + $b = ord($data[$i]); + if ($b === 0x3B) { + break; + } + if ($b === 0x21 && $i + 1 < $len) { + $i++; + $label = ord($data[$i]); + $i++; + if ($label === 0xF9 && $i + 6 <= $len) { + $i++; // block size (4) + $gcePacked = ord($data[$i]); + $i++; + $delayLo = ord($data[$i]); + $i++; + $delayHi = ord($data[$i]); + $i++; + $i++; // transp idx + $i++; // sub-block terminator + $pendingDelay = (($delayLo | ($delayHi << 8)) * 10) ?: 100; + $pendingDisposal = ($gcePacked >> 2) & 0x07; + } else { + while ($i < $len && ($size = ord($data[$i])) !== 0) { + $i += $size + 1; + } + $i++; + } + } elseif ($b === 0x2C && $i + 10 <= $len) { + $i++; + $fx = ord($data[$i]) | (ord($data[$i + 1]) << 8); + $fy = ord($data[$i + 2]) | (ord($data[$i + 3]) << 8); + $fw = ord($data[$i + 4]) | (ord($data[$i + 5]) << 8); + $fh = ord($data[$i + 6]) | (ord($data[$i + 7]) << 8); + $i += 8; + $imgPacked = ord($data[$i]); + $i++; + if ($imgPacked & 0x80) { + $i += 3 * (1 << (($imgPacked & 0x07) + 1)); + } + $i++; // LZW min code size + while ($i < $len && ($size = ord($data[$i])) !== 0) { + $i += $size + 1; + } + $i++; + + $frames[] = [ + 'x' => $fx, + 'y' => $fy, + 'w' => $fw, + 'h' => $fh, + 'delay' => $pendingDelay < 20 ? 100 : $pendingDelay, + 'disposal' => $pendingDisposal, + ]; + $pendingDelay = 100; + $pendingDisposal = 1; + } else { + $i++; + } + } + + return ['screenW' => $screenW, 'screenH' => $screenH, 'frames' => $frames]; } protected function getOptionString(): string { - $opts = ""; + $opts = ''; foreach ($this->options as $key => $value) { - if ($key === 'path') continue; - $opts .= " -$key \"$value\""; + if ($key === 'path') { + continue; + } + // Quote with `"` and escape the four characters Tcl interprets + // inside double quotes: backslash, dollar (var sub), open bracket + // (command sub), and closing quote. Brace-quoting was rejected + // because a trailing `\` in the value produces `{value\}`, where + // `\}` is read as a literal `}` and never closes the group. + $safe = str_replace( + ['\\', '$', '[', '"'], + ['\\\\', '\\$', '\\[', '\\"'], + (string) $value + ); + $opts .= " -{$key} \"{$safe}\""; } return $opts; } diff --git a/src/Widget/Input.php b/src/Widget/Input.php index 2c62c8d..723c0df 100644 --- a/src/Widget/Input.php +++ b/src/Widget/Input.php @@ -17,7 +17,7 @@ public function __construct(string $parentId, array $options = []) { protected function create(): void { $defaultText = $this->options['text'] ?? ''; $extra = $this->getOptionString(); - $this->tcl->evalTcl("entry .{$this->parentId}.{$this->id} -textvariable {$this->id} {$extra}"); + $this->tcl->evalTcl("entry {$this->tclPath} -textvariable {$this->id} {$extra}"); $this->tcl->evalTcl("set {$this->id} \"$defaultText\""); } @@ -45,6 +45,6 @@ public function setValue(string $text): void { public function onEnter(callable $callback): void { \PhpGui\ProcessTCL::getInstance()->registerCallback($this->id, $callback); - $this->tcl->evalTcl("bind .{$this->parentId}.{$this->id} {php::executeCallback {$this->id}}"); + $this->tcl->evalTcl("bind {$this->tclPath} {php::executeCallback {$this->id}}"); } } diff --git a/src/Widget/Label.php b/src/Widget/Label.php index 52fad8c..6c19bb9 100755 --- a/src/Widget/Label.php +++ b/src/Widget/Label.php @@ -20,7 +20,7 @@ protected function create(): void { $text = $this->options['text'] ?? ''; $extra = $this->getOptionString(); - $this->tcl->evalTcl("label .{$this->parentId}.{$this->id} -text \"{$text}\" {$extra}"); + $this->tcl->evalTcl("label {$this->tclPath} -text \"{$text}\" {$extra}"); } protected function getOptionString(): string @@ -35,26 +35,26 @@ protected function getOptionString(): string public function setText(string $text): void { - $this->tcl->evalTcl(".{$this->parentId}.{$this->id} configure -text \"{$text}\""); + $this->tcl->evalTcl("{$this->tclPath} configure -text \"{$text}\""); } public function setFont(string $font): void { - $this->tcl->evalTcl(".{$this->parentId}.{$this->id} configure -font \"{$font}\""); + $this->tcl->evalTcl("{$this->tclPath} configure -font \"{$font}\""); } public function setForeground(string $color): void { - $this->tcl->evalTcl(".{$this->parentId}.{$this->id} configure -fg \"{$color}\""); + $this->tcl->evalTcl("{$this->tclPath} configure -fg \"{$color}\""); } public function setBackground(string $color): void { - $this->tcl->evalTcl(".{$this->parentId}.{$this->id} configure -bg \"{$color}\""); + $this->tcl->evalTcl("{$this->tclPath} configure -bg \"{$color}\""); } public function setState(string $state): void { - $this->tcl->evalTcl(".{$this->parentId}.{$this->id} configure -state \"{$state}\""); + $this->tcl->evalTcl("{$this->tclPath} configure -state \"{$state}\""); } } diff --git a/src/Widget/Menu.php b/src/Widget/Menu.php index 1abf6f1..fea2837 100644 --- a/src/Widget/Menu.php +++ b/src/Widget/Menu.php @@ -20,13 +20,16 @@ protected function create(): void { $extra = $this->getOptionString(); + // Menus always live at the root path `.{$id}` regardless of where + // they are attached, so override the inherited tclPath. + $this->tclPath = ".{$this->id}"; + if ($this->type === 'main') { - // For main menu, attach directly to window - $this->tcl->evalTcl("menu .{$this->id} -tearoff 0 {$extra}"); - $this->tcl->evalTcl(".{$this->parentId} configure -menu .{$this->id}"); + // Attach to the parent window/toplevel using its full Tcl path. + $this->tcl->evalTcl("menu {$this->tclPath} -tearoff 0 {$extra}"); + $this->tcl->evalTcl("{$this->parentTclPath} configure -menu {$this->tclPath}"); } else { - // For submenus, use parent's path - $this->tcl->evalTcl("menu .{$this->id} -tearoff 0 {$extra}"); + $this->tcl->evalTcl("menu {$this->tclPath} -tearoff 0 {$extra}"); } } @@ -88,13 +91,4 @@ protected function formatOptions(array $options): string return implode(' ', $result); } - /** - * Destroys the menu widget. - * Menu creates its widget at `.{$id}` (not `.{$parent}.{$id}`), so we - * override the default AbstractWidget destroy path. - */ - public function destroy(): void - { - $this->tcl->evalTcl("destroy .{$this->id}"); - } } diff --git a/src/Widget/Menubutton.php b/src/Widget/Menubutton.php index 4aa5bde..0780915 100644 --- a/src/Widget/Menubutton.php +++ b/src/Widget/Menubutton.php @@ -15,8 +15,9 @@ public function __construct(string $parentId, array $options = []) { protected function create(): void { $text = $this->options['text'] ?? 'Menubutton'; - $this->tcl->evalTcl("menubutton .{$this->parentId}.{$this->id} -text \"{$text}\""); - $this->tcl->evalTcl("menu .{$this->parentId}.m_{$this->id} -tearoff 0"); - $this->tcl->evalTcl(".{$this->parentId}.{$this->id} configure -menu .{$this->parentId}.m_{$this->id}"); + $menuPath = "{$this->parentTclPath}.m_{$this->id}"; + $this->tcl->evalTcl("menubutton {$this->tclPath} -text \"{$text}\""); + $this->tcl->evalTcl("menu {$menuPath} -tearoff 0"); + $this->tcl->evalTcl("{$this->tclPath} configure -menu {$menuPath}"); } } diff --git a/src/Widget/Message.php b/src/Widget/Message.php index fbf7c78..8905093 100644 --- a/src/Widget/Message.php +++ b/src/Widget/Message.php @@ -16,7 +16,7 @@ public function __construct(?string $parentId, array $options = []) { protected function create(): void { $text = $this->options['text'] ?? 'Message'; - $path = ".{$this->parentId}.{$this->id}"; + $path = $this->tclPath; $this->tcl->evalTcl("toplevel {$path}"); $this->tcl->evalTcl("wm title {$path} \"Message\""); $this->tcl->evalTcl("label {$path}.msg -text \"{$text}\""); @@ -27,7 +27,7 @@ protected function create(): void { } public function setText(string $text): void { - $path = ".{$this->parentId}.{$this->id}"; + $path = $this->tclPath; $this->tcl->evalTcl("if {[winfo exists {$path}.msg]} { {$path}.msg configure -text \"{$text}\" }"); } } diff --git a/src/Widget/TopLevel.php b/src/Widget/TopLevel.php index fd53aa8..8b7978f 100644 --- a/src/Widget/TopLevel.php +++ b/src/Widget/TopLevel.php @@ -169,8 +169,4 @@ public function setText(string $text): void $this->tcl->evalTcl(".{$this->id}.child configure -text \"{$text}\""); } - public function destroy(): void - { - $this->tcl->evalTcl("destroy .{$this->id}"); - } } diff --git a/tests/widgets_test/ImageTest.php b/tests/widgets_test/ImageTest.php new file mode 100644 index 0000000..f47d893 --- /dev/null +++ b/tests/widgets_test/ImageTest.php @@ -0,0 +1,156 @@ + 'Image Test']); +$wid = $window->getId(); +$tcl = ProcessTCL::getInstance(); + +// Constructor: missing path +TestRunner::assertThrows( + fn() => new Image($wid, []), + \InvalidArgumentException::class, + 'Constructor without "path" throws InvalidArgumentException' +); + +// Constructor: nonexistent file +TestRunner::assertThrows( + fn() => new Image($wid, ['path' => $fixturesDir . '/does_not_exist.png']), + \RuntimeException::class, + 'Constructor with missing file throws RuntimeException' +); + +// JPG support via GD transcode (Tk core can't load JPEG directly) +if (is_file($jpgPath) && function_exists('imagecreatefromstring')) { + $jpgImage = new Image($wid, ['path' => $jpgPath]); + $jpgPathOut = ".{$wid}.{$jpgImage->getId()}"; + TestRunner::assertWidgetExists($jpgPathOut, 'JPG image label exists in Tcl (via GD transcode)'); + TestRunner::assertEqual($jpgPath, $jpgImage->getPath(), 'getPath() returns the original JPG path, not the temp PNG'); + TestRunner::assert($jpgImage->getWidth() > 0, 'JPG image reports a non-zero width'); + TestRunner::assert($jpgImage->getHeight() > 0, 'JPG image reports a non-zero height'); + + // The transcoded temp PNG should exist on disk while the widget lives, + // and disappear when destroy() is called. + $tempsBefore = glob(sys_get_temp_dir() . '/phpgui_img_*') ?: []; + TestRunner::assert(count($tempsBefore) >= 1, 'GD transcode produced a temp PNG on disk'); + + $jpgImage->destroy(); + TestRunner::assertWidgetGone($jpgPathOut, 'JPG image label gone after destroy()'); + $tempsAfter = glob(sys_get_temp_dir() . '/phpgui_img_*') ?: []; + TestRunner::assert(count($tempsAfter) < count($tempsBefore), 'destroy() unlinks the transcoded temp PNG'); +} + +// Constructor: unsupported extension +$bogus = $fixturesDir . '/bogus.xyz'; +file_put_contents($bogus, 'x'); +TestRunner::assertThrows( + fn() => new Image($wid, ['path' => $bogus]), + \RuntimeException::class, + 'Constructor with unsupported extension throws RuntimeException' +); +@unlink($bogus); + +// PNG creation +$image = new Image($wid, ['path' => $pngPath]); +$path = ".{$wid}.{$image->getId()}"; +$photo = 'phpgui_photo_' . $image->getId(); + +TestRunner::assertWidgetExists($path, 'PNG image label exists in Tcl'); +TestRunner::assertEqual($pngPath, $image->getPath(), 'getPath() returns resolved PNG path'); +TestRunner::assertEqual('1', trim($tcl->evalTcl("image inuse {$photo}")), 'photo image is in use by the label'); +TestRunner::assertEqual(1, $image->getWidth(), 'getWidth() reports fixture PNG width'); +TestRunner::assertEqual(1, $image->getHeight(), 'getHeight() reports fixture PNG height'); + +// Confirm the label's -image points at our photo (not e.g. a default). +$boundImage = trim($tcl->evalTcl("{$path} cget -image")); +TestRunner::assertEqual($photo, $boundImage, 'label -image is bound to our photo image'); + +// pack works through the inherited geometry manager +TestRunner::assertNoThrow(fn() => $image->pack(['pady' => 5]), 'pack() does not throw'); + +// Swap to a GIF: pixels reload, label keeps the same widget identity. +TestRunner::assertNoThrow(fn() => $image->setPath($gifPath), 'setPath() to GIF does not throw'); +TestRunner::assertEqual($gifPath, $image->getPath(), 'getPath() reflects the new path'); +TestRunner::assertWidgetExists($path, 'image label still exists after setPath()'); + +// setPath to a missing file should throw and leave state unchanged. +TestRunner::assertThrows( + fn() => $image->setPath($fixturesDir . '/missing.png'), + \RuntimeException::class, + 'setPath() with missing file throws RuntimeException' +); +TestRunner::assertEqual($gifPath, $image->getPath(), 'getPath() unchanged after failed setPath()'); + +// Path safety: a filename with spaces must work without breaking Tcl parsing. +$spacedPath = $fixturesDir . '/has space.png'; +copy($pngPath, $spacedPath); +TestRunner::assertNoThrow( + fn() => new Image($wid, ['path' => $spacedPath]), + 'Image with a space in its filename loads without Tcl parse error' +); +@unlink($spacedPath); + +// Single-frame GIF: parser sees 1 frame, isAnimated() is false, no after token registered. +$singleGif = new Image($wid, ['path' => $gifPath]); +TestRunner::assertEqual(1, $singleGif->getFrameCount(), 'single-frame GIF reports frameCount=1'); +TestRunner::assert(!$singleGif->isAnimated(), 'single-frame GIF is not animated'); +$singleAfter = trim($tcl->evalTcl("info exists ::phpgui_anim_after(phpgui_photo_{$singleGif->getId()})")); +TestRunner::assertEqual('0', $singleAfter, 'single-frame GIF schedules no after callback'); +$singleGif->destroy(); + +// Animated GIF: 4 frames, animation loop scheduled, cancelled cleanly on destroy. +if (is_file($animGifPath)) { + $anim = new Image($wid, ['path' => $animGifPath]); + $animPhoto = 'phpgui_photo_' . $anim->getId(); + TestRunner::assertEqual(4, $anim->getFrameCount(), 'animated GIF reports frameCount=4'); + TestRunner::assert($anim->isAnimated(), 'animated GIF reports isAnimated() = true'); + + $hasAfter = trim($tcl->evalTcl("info exists ::phpgui_anim_after({$animPhoto})")); + TestRunner::assertEqual('1', $hasAfter, 'animated GIF schedules an after callback'); + + // Drive the Tk event loop briefly so a frame transition actually happens. + $tcl->evalTcl('after 250 set ::phpgui_test_done 1; vwait ::phpgui_test_done'); + TestRunner::assertWidgetExists(".{$wid}.{$anim->getId()}", 'animated GIF widget still alive after event-loop tick'); + + // setPath() to a non-GIF must stop the animation. + $anim->setPath($pngPath); + $stillScheduled = trim($tcl->evalTcl("info exists ::phpgui_anim_after({$animPhoto})")); + TestRunner::assertEqual('0', $stillScheduled, 'setPath() to non-GIF cancels the animation'); + TestRunner::assert(!$anim->isAnimated(), 'isAnimated() flips to false after swapping to PNG'); + + // Swap back to animated, then destroy: after token must be cleared. + $anim->setPath($animGifPath); + TestRunner::assertEqual('1', trim($tcl->evalTcl("info exists ::phpgui_anim_after({$animPhoto})")), + 'after token re-registered when swapping back to animated GIF'); + $anim->destroy(); + $afterDestroy = trim($tcl->evalTcl("info exists ::phpgui_anim_after({$animPhoto})")); + TestRunner::assertEqual('0', $afterDestroy, 'destroy() cancels the animation'); +} + +// Destroy: both label and photo image must go away. +$image->destroy(); +TestRunner::assertWidgetGone($path, 'image label gone after destroy()'); +$exists = trim($tcl->evalTcl("lsearch [image names] {$photo}")); +TestRunner::assertEqual('-1', $exists, 'photo image deleted from Tk image table after destroy()'); + +TestRunner::summary(); diff --git a/tests/widgets_test/NestingTest.php b/tests/widgets_test/NestingTest.php new file mode 100644 index 0000000..23a9a51 --- /dev/null +++ b/tests/widgets_test/NestingTest.php @@ -0,0 +1,68 @@ + 'Nesting Test']); +$wid = $window->getId(); + +// Window's full Tcl path is just `.`. +TestRunner::assertEqual(".{$wid}", $window->getTclPath(), 'Window getTclPath returns root path'); + +// One-level nesting: Frame inside Window. +$outer = new Frame($window->getId()); +$expected = ".{$wid}.{$outer->getId()}"; +TestRunner::assertEqual($expected, $outer->getTclPath(), 'outer Frame path is .window.frame'); +TestRunner::assertWidgetExists($expected, 'outer Frame exists in Tcl'); +$outer->pack(['fill' => 'both', 'expand' => 1, 'padx' => 8, 'pady' => 8]); + +// Two-level nesting: Frame inside Frame. +$inner = new Frame($outer->getId()); +$expected = ".{$wid}.{$outer->getId()}.{$inner->getId()}"; +TestRunner::assertEqual($expected, $inner->getTclPath(), 'inner Frame path is .window.outer.inner'); +TestRunner::assertWidgetExists($expected, 'inner Frame exists in Tcl'); +$inner->pack(['fill' => 'both', 'expand' => 1]); + +// Three-level: Button inside the inner Frame. +$button = new Button($inner->getId(), ['text' => 'Deep']); +$expected = ".{$wid}.{$outer->getId()}.{$inner->getId()}.{$button->getId()}"; +TestRunner::assertEqual($expected, $button->getTclPath(), 'Button path is four levels deep'); +TestRunner::assertWidgetExists($expected, 'deeply-nested Button exists in Tcl'); +TestRunner::assertNoThrow(fn() => $button->pack(['pady' => 4]), 'pack() works on a deeply-nested Button'); + +// winfo parent should report the inner frame, proving Tk really sees the +// nesting (not just that the path string is well-formed). +$reportedParent = trim($tcl->evalTcl("winfo parent {$button->getTclPath()}")); +TestRunner::assertEqual($inner->getTclPath(), $reportedParent, 'Tk reports inner Frame as the Button parent'); + +// Label nested in inner frame, alongside the button — verifies sibling +// children of a non-root parent both render. +$label = new Label($inner->getId(), ['text' => 'Hello from depth']); +$label->pack(['pady' => 2]); +TestRunner::assertWidgetExists($label->getTclPath(), 'sibling Label inside inner Frame exists'); + +// Destroying the outer frame must take its descendants with it. +$buttonPath = $button->getTclPath(); +$outer->destroy(); +TestRunner::assertWidgetGone($outer->getTclPath(), 'outer Frame gone after destroy()'); +TestRunner::assertWidgetGone($buttonPath, 'descendant Button gone after outer destroy() (Tk cascade)'); + +TestRunner::summary(); diff --git a/tests/widgets_test/image/animated.gif b/tests/widgets_test/image/animated.gif new file mode 100644 index 0000000..4a7465a Binary files /dev/null and b/tests/widgets_test/image/animated.gif differ diff --git a/tests/widgets_test/image/example.jpg b/tests/widgets_test/image/example.jpg new file mode 100644 index 0000000..22563e4 Binary files /dev/null and b/tests/widgets_test/image/example.jpg differ diff --git a/tests/widgets_test/image/test.gif b/tests/widgets_test/image/test.gif new file mode 100644 index 0000000..f191b28 Binary files /dev/null and b/tests/widgets_test/image/test.gif differ diff --git a/tests/widgets_test/image/test.jpg b/tests/widgets_test/image/test.jpg new file mode 100644 index 0000000..b693b8a Binary files /dev/null and b/tests/widgets_test/image/test.jpg differ diff --git a/tests/widgets_test/image/test.png b/tests/widgets_test/image/test.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/tests/widgets_test/image/test.png differ