Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 52 additions & 26 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Binary file added assets/54396379.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/happy-cat.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
121 changes: 121 additions & 0 deletions docs/Image.md
Original file line number Diff line number Diff line change
@@ -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_<id>`). 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.
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading