Skip to content

Commit 4672ed5

Browse files
Add widget implementations for Progressbar, RadioGroup, Radiobutton, Scale, and Spinbox
- Implemented Progressbar widget with determinate and indeterminate modes, including methods for setting values, starting/stopping animations, and configuring maximum values. - Created RadioGroup to manage a set of mutually-exclusive Radiobuttons, allowing for shared state and change event handling. - Developed Radiobutton widget that integrates with RadioGroup, ensuring mutual exclusivity and providing per-button command callbacks. - Added Scale widget for slider input, supporting both horizontal and vertical orientations, with value change notifications. - Introduced Spinbox widget for bounded numeric input, allowing for both typing and button-based adjustments, with support for enumerated values. - Added regression tests for all new widgets to ensure functionality and correctness.
1 parent 1ebbc0f commit 4672ed5

17 files changed

Lines changed: 1813 additions & 0 deletions

docs/Listbox.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Listbox Widget
2+
3+
The **Listbox** widget displays a scrollable list of strings the user can select from. _New in v1.9._
4+
5+
For tall content, pair with [`Scrollbar::attachTo()`](Scrollbar.md).
6+
7+
---
8+
9+
### Constructor
10+
11+
```php
12+
new Listbox(string $parentId, array $options = [])
13+
```
14+
15+
| Parameter | Type | Description |
16+
|-------------|----------|-------------------------------------------|
17+
| `$parentId` | `string` | `getId()` of the parent widget. |
18+
| `$options` | `array` | Configuration options — see below. |
19+
20+
---
21+
22+
### Options
23+
24+
| Key | Type | Description |
25+
|--------------|------------------|----------------------------------------------------------------------------------------------|
26+
| `items` | `list<string>` | Initial items. Equivalent to calling `addItem()` for each one after construction. |
27+
| `selectmode` | `string` | `'browse'` (default), `'single'`, `'multiple'`, or `'extended'`. Other values throw. |
28+
| `height` | `int` | Visible rows. Defaults to 10. |
29+
| `width` | `int` | Width in average characters. |
30+
| `bg`/`fg` | `string` | Background / foreground colours. |
31+
| `font` | `string` | Font specification. |
32+
33+
Any other option is forwarded to Tk's `listbox` command (run through safe-quoting).
34+
35+
#### Select modes
36+
37+
| Mode | Behaviour |
38+
|------------|-----------------------------------------------------------------|
39+
| `browse` | Single-select; arrow keys move and select. |
40+
| `single` | Single-select; click only. |
41+
| `multiple` | Toggle on click; multi-select. |
42+
| `extended` | Shift/Ctrl multi-select like a file picker. |
43+
44+
---
45+
46+
### Examples
47+
48+
**Static list with selection callback:**
49+
```php
50+
use PhpGui\Widget\{Listbox, Label};
51+
52+
$status = new Label($window->getId(), ['text' => 'pick something']);
53+
$status->pack();
54+
55+
$cities = new Listbox($window->getId(), [
56+
'items' => ['Berlin', 'Tokyo', 'Lagos', 'São Paulo'],
57+
'height' => 6,
58+
]);
59+
$cities->pack(['fill' => 'x', 'padx' => 12, 'pady' => 6]);
60+
61+
$cities->onSelect(function (Listbox $lb) use ($status) {
62+
$status->setText('selected: ' . ($lb->getItem($lb->getSelectedIndex()) ?? '(none)'));
63+
});
64+
```
65+
66+
**Multi-select with a scrollbar:**
67+
```php
68+
use PhpGui\Widget\{Frame, Listbox, Scrollbar};
69+
70+
$frame = new Frame($window->getId());
71+
$frame->pack(['fill' => 'both', 'expand' => 1]);
72+
73+
$list = new Listbox($frame->getId(), [
74+
'selectmode' => 'extended',
75+
'height' => 12,
76+
]);
77+
$list->pack(['side' => 'left', 'fill' => 'both', 'expand' => 1]);
78+
foreach (range(1, 100) as $i) {
79+
$list->addItem("Row {$i}");
80+
}
81+
82+
Scrollbar::attachTo($list, 'vertical');
83+
```
84+
85+
---
86+
87+
### Methods
88+
89+
| Method | Signature | Description |
90+
|-------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------|
91+
| `addItem()` | `(string $item): void` | Append to the end. Tcl-special characters preserved literally. |
92+
| `setItems()` | `(list<string> $items): void` | Replace every item in one call. More efficient than `clear()` + loop. |
93+
| `removeItem()` | `(int $index): void` | Remove by 0-based index. Negative or out-of-range indices are silently ignored. |
94+
| `clear()` | `(): void` | Remove every item. |
95+
| `size()` | `(): int` | Total item count. |
96+
| `getItem()` | `(int $index): ?string` | Item at `$index`, or `null` if out of range. |
97+
| `getAllItems()` | `(): list<string>` | All items in display order. |
98+
| `getSelectedIndices()` | `(): list<int>` | All selected indices (ascending). Empty array if no selection. |
99+
| `getSelectedIndex()` | `(): ?int` | First selected index, or `null`. Convenient for browse/single mode. |
100+
| `getSelectedItems()` | `(): list<string>` | Selected items as strings. |
101+
| `setSelection()` | `(list<int> $indices): void` | Replace the selection. Out-of-range indices are silently dropped; pass `[]` to clear. |
102+
| `onSelect()` | `(callable $cb): void` | Fire `$cb($listbox)` on `<<ListboxSelect>>`. Note: programmatic `setSelection()` does NOT fire this event. |
103+
| `pack/place/grid` | `(array $opts = []): void` | Inherited geometry managers. |
104+
| `destroy()` | `(): void` | Removes the widget and frees the select callback. |
105+
106+
---
107+
108+
### Notes
109+
110+
- Tk fires the `<<ListboxSelect>>` virtual event on user-driven selection changes (mouse, keyboard). Programmatic changes via `setSelection()` do **not** fire it — invoke your handler manually if you need that behaviour.
111+
- Items are inserted via Tcl variables, so newlines, brackets, dollars, and quotes pass through verbatim. No escaping needed at the call site.
112+
- A listbox without an attached scrollbar still scrolls via mouse wheel and arrow keys; `Scrollbar::attachTo()` adds the visual indicator.

docs/Progressbar.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Progressbar Widget
2+
3+
The **Progressbar** widget shows progress for long-running tasks. Wraps Tk's `ttk::progressbar`. _New in v1.9._
4+
5+
Two modes:
6+
7+
- **`determinate`** (default) — bar fills from 0 to `maximum`. Drive with `setValue()` or `step()` as work completes.
8+
- **`indeterminate`** — animated bouncing bar for "doing something, can't measure how much". Drive with `start()` / `stop()`.
9+
10+
---
11+
12+
### Constructor
13+
14+
```php
15+
new Progressbar(string $parentId, array $options = [])
16+
```
17+
18+
| Parameter | Type | Description |
19+
|-------------|----------|------------------------------------------|
20+
| `$parentId` | `string` | `getId()` of the parent widget. |
21+
| `$options` | `array` | Configuration options — see below. |
22+
23+
---
24+
25+
### Options
26+
27+
| Key | Type | Description |
28+
|-----------|------------------|-----------------------------------------------------------------------|
29+
| `mode` | `string` | `'determinate'` (default) or `'indeterminate'`. Other values throw. |
30+
| `orient` | `string` | `'horizontal'` (default) or `'vertical'`. Other values throw. |
31+
| `maximum` | `float` | Determinate mode end value (default `100`). |
32+
| `value` | `float` | Initial position (default `0`). |
33+
| `length` | `int` | Length of the bar in pixels. |
34+
35+
---
36+
37+
### Examples
38+
39+
**Determinate, ticking up as work happens:**
40+
```php
41+
use PhpGui\Widget\Progressbar;
42+
43+
$bar = new Progressbar($window->getId(), ['maximum' => $totalRows]);
44+
$bar->pack(['fill' => 'x', 'padx' => 12]);
45+
46+
foreach ($rows as $i => $row) {
47+
process($row);
48+
$bar->setValue($i + 1);
49+
// give Tk a chance to repaint between iterations
50+
\PhpGui\ProcessTCL::getInstance()->evalTcl('update');
51+
}
52+
```
53+
54+
**Indeterminate "busy" bar:**
55+
```php
56+
$busy = new Progressbar($window->getId(), [
57+
'mode' => 'indeterminate',
58+
'length' => 240,
59+
]);
60+
$busy->pack();
61+
$busy->start(); // animate
62+
// … later, when the task finishes …
63+
$busy->stop();
64+
```
65+
66+
---
67+
68+
### Methods
69+
70+
| Method | Signature | Description |
71+
|-------------------|------------------------------------|---------------------------------------------------------------------------------------------------|
72+
| `getValue()` | `(): float` | Determinate-mode current value. |
73+
| `setValue()` | `(float $value): void` | Set the position. Tk clamps to `[0, maximum]`. |
74+
| `step()` | `(float $amount = 1.0): void` | Add `$amount` to the current value. |
75+
| `getMaximum()` | `(): float` | Determinate-mode end value. |
76+
| `setMaximum()` | `(float $maximum): void` | Update the end value. |
77+
| `getMode()` | `(): string` | `'determinate'` or `'indeterminate'`. |
78+
| `setMode()` | `(string $mode): void` | Switch modes at runtime. Invalid modes throw. |
79+
| `start()` | `(int $intervalMs = 50): void` | Begin indeterminate animation. `$intervalMs` < 1 throws. |
80+
| `stop()` | `(): void` | Stop indeterminate animation. |
81+
| `pack/place/grid` | `(array $opts = []): void` | Inherited. |
82+
| `destroy()` | `(): void` | Removes the widget. |
83+
84+
---
85+
86+
### Notes
87+
88+
- A long-running PHP loop blocks Tk's event queue. If your work happens in PHP between `setValue()` calls, the bar won't repaint — call `ProcessTCL::evalTcl('update')` periodically to flush pending paints.
89+
- `start()` / `stop()` are no-ops in determinate mode; switch with `setMode('indeterminate')` first.
90+
- `step()` is convenient for "tick by tick" reporting where you don't track the absolute value yourself.

docs/Radiobutton.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Radiobutton & RadioGroup
2+
3+
The **Radiobutton** widget represents one option in a mutually-exclusive set. Selecting one button automatically deselects every other Radiobutton sharing the same `RadioGroup`. _New in v1.9._
4+
5+
`RadioGroup` is the missing piece if you've used Tk before: instead of every radio button declaring `-variable` on its own and silently breaking when two buttons happen to land on the same name, the group is a first-class object you create up front.
6+
7+
---
8+
9+
### RadioGroup
10+
11+
```php
12+
new RadioGroup(string $defaultValue = '')
13+
```
14+
15+
A group owns a single Tcl variable. Every Radiobutton attached to the group shares that variable, so Tk handles the deselection logic for you.
16+
17+
| Method | Signature | Description |
18+
|------------------------------|----------------------------------|--------------------------------------------------------------------------|
19+
| `getValue()` | `(): string` | Currently selected value (the `$value` of the active radio). |
20+
| `setValue()` | `(string $value): void` | Programmatically pick a value. Fires every registered `onChange` handler. |
21+
| `getDefault()` | `(): string` | The default passed to the constructor. |
22+
| `getVariableName()` | `(): string` | Underlying Tcl variable name. Mostly internal; useful for advanced uses. |
23+
| `onChange()` | `(callable $h): void` | Register `$h(string $value)` — fires on every change (user click + `setValue`). |
24+
25+
---
26+
27+
### Radiobutton
28+
29+
```php
30+
new Radiobutton(
31+
string $parentId,
32+
RadioGroup $group,
33+
string $value,
34+
array $options = []
35+
)
36+
```
37+
38+
| Parameter | Type | Description |
39+
|-------------|--------------|--------------------------------------------------------------------------------------------|
40+
| `$parentId` | `string` | `getId()` of the parent widget. |
41+
| `$group` | `RadioGroup` | The group this radio belongs to. |
42+
| `$value` | `string` | The value the group reports when this radio is selected. |
43+
| `$options` | `array` | Tk options. `text` defaults to `$value`; `command` receives the value when this radio is picked. |
44+
45+
| Method | Signature | Description |
46+
|-----------------|--------------------------|---------------------------------------------------|
47+
| `select()` | `(): void` | Programmatically activate this radio. |
48+
| `isSelected()` | `(): bool` | True if this radio is the currently-active one. |
49+
| `getValue()` | `(): string` | The value this radio represents. |
50+
| `getGroup()` | `(): RadioGroup` | The owning group. |
51+
52+
---
53+
54+
### Examples
55+
56+
**Basic group with three buttons:**
57+
```php
58+
use PhpGui\Widget\{RadioGroup, Radiobutton, Label};
59+
60+
$tier = new RadioGroup('basic');
61+
$basic = new Radiobutton($window->getId(), $tier, 'basic', ['text' => 'Basic']);
62+
$pro = new Radiobutton($window->getId(), $tier, 'pro', ['text' => 'Pro']);
63+
$ent = new Radiobutton($window->getId(), $tier, 'ent', ['text' => 'Enterprise']);
64+
65+
$basic->pack(['anchor' => 'w']);
66+
$pro->pack(['anchor' => 'w']);
67+
$ent->pack(['anchor' => 'w']);
68+
69+
$status = new Label($window->getId(), ['text' => "tier: {$tier->getValue()}"]);
70+
$status->pack();
71+
72+
$tier->onChange(function (string $value) use ($status) {
73+
$status->setText("tier: {$value}");
74+
});
75+
```
76+
77+
**Per-radio command:**
78+
```php
79+
new Radiobutton($window->getId(), $tier, 'pro', [
80+
'text' => 'Pro',
81+
'command' => function (string $value) {
82+
echo "user picked: {$value}\n";
83+
},
84+
]);
85+
```
86+
87+
The per-radio `command` receives the radio's value as a string. The group-level `onChange` also fires for every change — use whichever fits your model.
88+
89+
**Two independent groups:**
90+
```php
91+
$tier = new RadioGroup('basic');
92+
$theme = new RadioGroup('light');
93+
94+
new Radiobutton($win, $tier, 'basic', ['text' => 'Basic'])->pack();
95+
new Radiobutton($win, $tier, 'pro', ['text' => 'Pro'])->pack();
96+
97+
new Radiobutton($win, $theme, 'light', ['text' => 'Light'])->pack();
98+
new Radiobutton($win, $theme, 'dark', ['text' => 'Dark'])->pack();
99+
```
100+
101+
The two groups don't interact — each manages its own Tcl variable.
102+
103+
---
104+
105+
### Notes
106+
107+
- All radios in a group must share the **same** `RadioGroup` instance. Two separately-constructed groups are independent even if their default values are identical.
108+
- `RadioGroup::setValue()` fires every registered `onChange` handler. Tk's native `-command` option fires only on user clicks, so to keep both code paths consistent the framework dispatches handlers on `setValue()` itself.
109+
- Radiobutton labels are run through the safe-quoting helper, so user-supplied text (e.g. translated strings) cannot inject Tcl.

0 commit comments

Comments
 (0)