nextpytk 0.4.2__tar.gz → 0.4.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nextpytk-0.4.2 → nextpytk-0.4.3}/.gitignore +2 -1
- {nextpytk-0.4.2 → nextpytk-0.4.3}/PKG-INFO +145 -1
- {nextpytk-0.4.2 → nextpytk-0.4.3}/README.md +144 -0
- {nextpytk-0.4.2 → nextpytk-0.4.3}/ROADMAP.md +17 -4
- {nextpytk-0.4.2 → nextpytk-0.4.3}/pyproject.toml +1 -1
- {nextpytk-0.4.2 → nextpytk-0.4.3}/src/nextpytk/app.py +143 -6
- {nextpytk-0.4.2 → nextpytk-0.4.3}/src/nextpytk/layout.py +603 -35
- {nextpytk-0.4.2 → nextpytk-0.4.3}/src/nextpytk/types.py +21 -0
- {nextpytk-0.4.2 → nextpytk-0.4.3}/src/nextpytk/widgets.py +1 -0
- {nextpytk-0.4.2 → nextpytk-0.4.3}/LICENSE +0 -0
- {nextpytk-0.4.2 → nextpytk-0.4.3}/src/nextpytk/__init__.py +0 -0
- {nextpytk-0.4.2 → nextpytk-0.4.3}/src/nextpytk/theme.py +0 -0
- {nextpytk-0.4.2 → nextpytk-0.4.3}/src/nextpytk/tokens.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nextpytk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: Accessible, declarative Tkinter applications from ordinary Python functions.
|
|
5
5
|
Project-URL: Homepage, https://github.com/nishimotz/nextpytk
|
|
6
6
|
Project-URL: Repository, https://github.com/nishimotz/nextpytk
|
|
@@ -400,6 +400,118 @@ Each name gets its own pack-based section. Extra kwargs forwarded to `section()`
|
|
|
400
400
|
Layout.from_list(["a", "b"], fill="both", expand=True)
|
|
401
401
|
```
|
|
402
402
|
|
|
403
|
+
### Layout spacing
|
|
404
|
+
|
|
405
|
+
The default block padding comes from the design token `SPACE[1]` (4 px). Pass
|
|
406
|
+
`spacing=...` to a `Layout` constructor to change the default padding for every
|
|
407
|
+
block it creates:
|
|
408
|
+
|
|
409
|
+
```python
|
|
410
|
+
from nextpytk import tokens
|
|
411
|
+
|
|
412
|
+
layout = Layout(spacing=2).section("a").section("b").grid().widget("c").end_grid()
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
`spacing` accepts a key from `nextpytk.tokens.SPACE` (1, 2, 3, 4, 6, 8). It sets
|
|
416
|
+
the default `padx` and `pady` used by `section()`, `grid()`, `paned()`, grid
|
|
417
|
+
`widget()` placements, and nested frames. Explicit `padx`/`pady` arguments still
|
|
418
|
+
override the default.
|
|
419
|
+
|
|
420
|
+
For finer control, pass `padx` or `pady` directly to the constructor:
|
|
421
|
+
|
|
422
|
+
```python
|
|
423
|
+
Layout(padx=tokens.SPACE[4], pady=tokens.SPACE[3]).section("a")
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Nested frames
|
|
427
|
+
|
|
428
|
+
A `Layout` can be placed inside a named frame to group widgets visually without
|
|
429
|
+
mixing pack and grid in the same parent. The inner layout owns its own spacing
|
|
430
|
+
and may use any layout style (pack sections, grid, paned, or further nested
|
|
431
|
+
frames).
|
|
432
|
+
|
|
433
|
+
```python
|
|
434
|
+
inner = Layout().section("a", "b")
|
|
435
|
+
outer = Layout().section("title").frame("group", inner).section("ok")
|
|
436
|
+
app.run(layout=outer)
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
`frame(name, layout, side=..., fill=..., expand=..., padx=..., pady=...)` packs
|
|
440
|
+
the inner layout as a single block. The frame itself is registered under `name`,
|
|
441
|
+
so it can also be placed inside a grid cell:
|
|
442
|
+
|
|
443
|
+
```python
|
|
444
|
+
outer = (
|
|
445
|
+
Layout()
|
|
446
|
+
.grid()
|
|
447
|
+
.widget("label")
|
|
448
|
+
.widget("group", sticky="nsew")
|
|
449
|
+
.end_grid()
|
|
450
|
+
.frame("group", Layout().section("a", "b"))
|
|
451
|
+
)
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Paired layout
|
|
455
|
+
|
|
456
|
+
`Layout.paired(left, right, ...)` places two widgets side by side in a
|
|
457
|
+
single two-column frame. It is a lighter alternative to `app.paned` for
|
|
458
|
+
diff/compare views:
|
|
459
|
+
|
|
460
|
+
```python
|
|
461
|
+
layout = (
|
|
462
|
+
Layout()
|
|
463
|
+
.section("info")
|
|
464
|
+
.paired(
|
|
465
|
+
"left_text",
|
|
466
|
+
"right_text",
|
|
467
|
+
weight=(1, 1),
|
|
468
|
+
fill=Fill.BOTH,
|
|
469
|
+
expand=True,
|
|
470
|
+
sync_yscroll=True,
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Options:
|
|
476
|
+
|
|
477
|
+
| Argument | Default | Description |
|
|
478
|
+
|----------|---------|-------------|
|
|
479
|
+
| `weight` | `(1, 1)` | Two-column `(left, right)` grid weights |
|
|
480
|
+
| `fill` | `"x"` | Frame `fill` passed to `pack` |
|
|
481
|
+
| `expand` | `False` | Frame `expand` passed to `pack` |
|
|
482
|
+
| `sync_yscroll` | `False` | Wire the two widgets' y-scroll commands together |
|
|
483
|
+
| `side`, `padx`, `pady`, `anchor` | — | Standard `Layout` block placement options |
|
|
484
|
+
|
|
485
|
+
When `sync_yscroll=True` and both widgets are `@app.text` widgets, scrolling
|
|
486
|
+
either pane moves the other. For text widgets the same effect can also be
|
|
487
|
+
achieved per-widget with `@app.text(..., sync_yscroll_with="other")`; paired
|
|
488
|
+
layout provides a layout-level switch that works without editing widget
|
|
489
|
+
declarations.
|
|
490
|
+
|
|
491
|
+
### Cluster layout
|
|
492
|
+
|
|
493
|
+
`Layout.cluster()` is a wrapping flow: widgets are not stretched to fill a
|
|
494
|
+
column; each keeps the width implied by its content or `width=` and they lay
|
|
495
|
+
out left to right, wrapping to the next row whenever the next widget no longer
|
|
496
|
+
fits in the remaining frame width. Widgets in the same row are vertically
|
|
497
|
+
centered on their midline, so a tall `entry` aligns cleanly with a shorter
|
|
498
|
+
`button` or `checkbutton`. The gap inherits the layout spacing by default.
|
|
499
|
+
|
|
500
|
+
```python
|
|
501
|
+
TAGS = ["python", "tkinter", "async", "uv", "type hints"]
|
|
502
|
+
for tag in TAGS:
|
|
503
|
+
@app.button(tag, label=f"#{tag}")
|
|
504
|
+
def on_tag(values, tag=tag):
|
|
505
|
+
return {"msg": f"Selected: {tag}"}
|
|
506
|
+
|
|
507
|
+
app.run(layout=Layout(spacing=2).status("msg").cluster(*TAGS))
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Cluster is ideal for tag clouds, toolbars, and filter UIs. The layout recomputes
|
|
511
|
+
rows automatically when the window is resized. Pass `gap=...` to override the
|
|
512
|
+
spacing default, and `side`/`fill`/`expand` to control how the cluster frame is
|
|
513
|
+
packed.
|
|
514
|
+
|
|
403
515
|
### Fluent DSL
|
|
404
516
|
|
|
405
517
|
**Pack sections:**
|
|
@@ -487,6 +599,7 @@ app.run(layout=b.build())
|
|
|
487
599
|
| `@app.radiobutton(name, text=..., value=..., group=..., font=...)` | ttk.Radiobutton | selected value `str` | `dict` |
|
|
488
600
|
| `@app.combobox(name, values=..., values_key=..., readonly=..., font=...)` | ttk.Combobox | selected value `str` | `dict` |
|
|
489
601
|
| `@app.menubar(name)` | tk.Menu (window menubar) | — | menu item list |
|
|
602
|
+
| `@app.filepicker(name, mode=..., label=..., title=..., filetypes=..., ...)` | ttk.Button → tkinter.filedialog | selected path(s) `str`, `list[str]`, or `None` | `dict` |
|
|
490
603
|
| `@app.text(name, width=..., height=..., font=...)` | tk.Text | full content `str` | `dict` |
|
|
491
604
|
| `@app.scale(name, from_=..., to=..., orient=...)` | ttk.Scale | value `str` | `dict` |
|
|
492
605
|
| `@app.spinbox(name, from_=..., to=..., values=..., font=...)` | ttk.Spinbox | value `str` | `dict` |
|
|
@@ -495,6 +608,35 @@ app.run(layout=b.build())
|
|
|
495
608
|
|
|
496
609
|
`@app.status` sets schema / accessible `role="status"` metadata. It is **not** an ARIA live region yet (planned for a later release). Prefer it for operation feedback labels; use `@app.label` for static or high-frequency mirror text.
|
|
497
610
|
|
|
611
|
+
### File picker
|
|
612
|
+
|
|
613
|
+
`@app.filepicker` creates a button that opens a tkinter file dialog.
|
|
614
|
+
The selected path(s) are passed to the callback; `None` on cancel.
|
|
615
|
+
|
|
616
|
+
```python
|
|
617
|
+
@app.filepicker("open_file", mode="open", label="Open file",
|
|
618
|
+
title="Open file", filetypes=[("Text files", "*.txt")])
|
|
619
|
+
def pick_open_file(path: str | None) -> dict[str, Any]:
|
|
620
|
+
return {"open_path": path}
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Modes: `"open"` (default), `"open_multiple"`, `"save"`, `"directory"`.
|
|
624
|
+
|
|
625
|
+
A filepicker can also be invoked from a menubar item by using its name
|
|
626
|
+
as the `command`:
|
|
627
|
+
|
|
628
|
+
```python
|
|
629
|
+
@app.filepicker("m_open", mode="open", title="Open file")
|
|
630
|
+
def m_open(path):
|
|
631
|
+
return {"open_path": path}
|
|
632
|
+
|
|
633
|
+
@app.menubar("menu")
|
|
634
|
+
def menu_bar():
|
|
635
|
+
return [{"label": "File", "items": [
|
|
636
|
+
{"label": "Open...", "command": "m_open"},
|
|
637
|
+
]}]
|
|
638
|
+
```
|
|
639
|
+
|
|
498
640
|
`app.run(stages=..., tabposition=...)` and `@app.stages` provide state-driven screen switching (one visible stage at a time). Theme helpers (`apply_theme`, `tokens`, layout chrome) ship in the package root — see `examples/header_demo.py`.
|
|
499
641
|
|
|
500
642
|
Common options:
|
|
@@ -596,7 +738,9 @@ uv run python examples/widget_gallery.py # all widget types
|
|
|
596
738
|
uv run python examples/header_demo.py # Layout.header / .status chrome
|
|
597
739
|
uv run python examples/combobox_demo.py # ttk.Combobox
|
|
598
740
|
uv run python examples/menubar_demo.py # menubar
|
|
741
|
+
uv run python examples/filepicker_demo.py # file picker
|
|
599
742
|
uv run python examples/disk_usage_flat_async.py # ncdu-style viewer (async)
|
|
743
|
+
uv run python examples/paired_demo.py # side-by-side paired layout with y-scroll sync
|
|
600
744
|
```
|
|
601
745
|
|
|
602
746
|
---
|
|
@@ -376,6 +376,118 @@ Each name gets its own pack-based section. Extra kwargs forwarded to `section()`
|
|
|
376
376
|
Layout.from_list(["a", "b"], fill="both", expand=True)
|
|
377
377
|
```
|
|
378
378
|
|
|
379
|
+
### Layout spacing
|
|
380
|
+
|
|
381
|
+
The default block padding comes from the design token `SPACE[1]` (4 px). Pass
|
|
382
|
+
`spacing=...` to a `Layout` constructor to change the default padding for every
|
|
383
|
+
block it creates:
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
from nextpytk import tokens
|
|
387
|
+
|
|
388
|
+
layout = Layout(spacing=2).section("a").section("b").grid().widget("c").end_grid()
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`spacing` accepts a key from `nextpytk.tokens.SPACE` (1, 2, 3, 4, 6, 8). It sets
|
|
392
|
+
the default `padx` and `pady` used by `section()`, `grid()`, `paned()`, grid
|
|
393
|
+
`widget()` placements, and nested frames. Explicit `padx`/`pady` arguments still
|
|
394
|
+
override the default.
|
|
395
|
+
|
|
396
|
+
For finer control, pass `padx` or `pady` directly to the constructor:
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
Layout(padx=tokens.SPACE[4], pady=tokens.SPACE[3]).section("a")
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Nested frames
|
|
403
|
+
|
|
404
|
+
A `Layout` can be placed inside a named frame to group widgets visually without
|
|
405
|
+
mixing pack and grid in the same parent. The inner layout owns its own spacing
|
|
406
|
+
and may use any layout style (pack sections, grid, paned, or further nested
|
|
407
|
+
frames).
|
|
408
|
+
|
|
409
|
+
```python
|
|
410
|
+
inner = Layout().section("a", "b")
|
|
411
|
+
outer = Layout().section("title").frame("group", inner).section("ok")
|
|
412
|
+
app.run(layout=outer)
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
`frame(name, layout, side=..., fill=..., expand=..., padx=..., pady=...)` packs
|
|
416
|
+
the inner layout as a single block. The frame itself is registered under `name`,
|
|
417
|
+
so it can also be placed inside a grid cell:
|
|
418
|
+
|
|
419
|
+
```python
|
|
420
|
+
outer = (
|
|
421
|
+
Layout()
|
|
422
|
+
.grid()
|
|
423
|
+
.widget("label")
|
|
424
|
+
.widget("group", sticky="nsew")
|
|
425
|
+
.end_grid()
|
|
426
|
+
.frame("group", Layout().section("a", "b"))
|
|
427
|
+
)
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### Paired layout
|
|
431
|
+
|
|
432
|
+
`Layout.paired(left, right, ...)` places two widgets side by side in a
|
|
433
|
+
single two-column frame. It is a lighter alternative to `app.paned` for
|
|
434
|
+
diff/compare views:
|
|
435
|
+
|
|
436
|
+
```python
|
|
437
|
+
layout = (
|
|
438
|
+
Layout()
|
|
439
|
+
.section("info")
|
|
440
|
+
.paired(
|
|
441
|
+
"left_text",
|
|
442
|
+
"right_text",
|
|
443
|
+
weight=(1, 1),
|
|
444
|
+
fill=Fill.BOTH,
|
|
445
|
+
expand=True,
|
|
446
|
+
sync_yscroll=True,
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Options:
|
|
452
|
+
|
|
453
|
+
| Argument | Default | Description |
|
|
454
|
+
|----------|---------|-------------|
|
|
455
|
+
| `weight` | `(1, 1)` | Two-column `(left, right)` grid weights |
|
|
456
|
+
| `fill` | `"x"` | Frame `fill` passed to `pack` |
|
|
457
|
+
| `expand` | `False` | Frame `expand` passed to `pack` |
|
|
458
|
+
| `sync_yscroll` | `False` | Wire the two widgets' y-scroll commands together |
|
|
459
|
+
| `side`, `padx`, `pady`, `anchor` | — | Standard `Layout` block placement options |
|
|
460
|
+
|
|
461
|
+
When `sync_yscroll=True` and both widgets are `@app.text` widgets, scrolling
|
|
462
|
+
either pane moves the other. For text widgets the same effect can also be
|
|
463
|
+
achieved per-widget with `@app.text(..., sync_yscroll_with="other")`; paired
|
|
464
|
+
layout provides a layout-level switch that works without editing widget
|
|
465
|
+
declarations.
|
|
466
|
+
|
|
467
|
+
### Cluster layout
|
|
468
|
+
|
|
469
|
+
`Layout.cluster()` is a wrapping flow: widgets are not stretched to fill a
|
|
470
|
+
column; each keeps the width implied by its content or `width=` and they lay
|
|
471
|
+
out left to right, wrapping to the next row whenever the next widget no longer
|
|
472
|
+
fits in the remaining frame width. Widgets in the same row are vertically
|
|
473
|
+
centered on their midline, so a tall `entry` aligns cleanly with a shorter
|
|
474
|
+
`button` or `checkbutton`. The gap inherits the layout spacing by default.
|
|
475
|
+
|
|
476
|
+
```python
|
|
477
|
+
TAGS = ["python", "tkinter", "async", "uv", "type hints"]
|
|
478
|
+
for tag in TAGS:
|
|
479
|
+
@app.button(tag, label=f"#{tag}")
|
|
480
|
+
def on_tag(values, tag=tag):
|
|
481
|
+
return {"msg": f"Selected: {tag}"}
|
|
482
|
+
|
|
483
|
+
app.run(layout=Layout(spacing=2).status("msg").cluster(*TAGS))
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Cluster is ideal for tag clouds, toolbars, and filter UIs. The layout recomputes
|
|
487
|
+
rows automatically when the window is resized. Pass `gap=...` to override the
|
|
488
|
+
spacing default, and `side`/`fill`/`expand` to control how the cluster frame is
|
|
489
|
+
packed.
|
|
490
|
+
|
|
379
491
|
### Fluent DSL
|
|
380
492
|
|
|
381
493
|
**Pack sections:**
|
|
@@ -463,6 +575,7 @@ app.run(layout=b.build())
|
|
|
463
575
|
| `@app.radiobutton(name, text=..., value=..., group=..., font=...)` | ttk.Radiobutton | selected value `str` | `dict` |
|
|
464
576
|
| `@app.combobox(name, values=..., values_key=..., readonly=..., font=...)` | ttk.Combobox | selected value `str` | `dict` |
|
|
465
577
|
| `@app.menubar(name)` | tk.Menu (window menubar) | — | menu item list |
|
|
578
|
+
| `@app.filepicker(name, mode=..., label=..., title=..., filetypes=..., ...)` | ttk.Button → tkinter.filedialog | selected path(s) `str`, `list[str]`, or `None` | `dict` |
|
|
466
579
|
| `@app.text(name, width=..., height=..., font=...)` | tk.Text | full content `str` | `dict` |
|
|
467
580
|
| `@app.scale(name, from_=..., to=..., orient=...)` | ttk.Scale | value `str` | `dict` |
|
|
468
581
|
| `@app.spinbox(name, from_=..., to=..., values=..., font=...)` | ttk.Spinbox | value `str` | `dict` |
|
|
@@ -471,6 +584,35 @@ app.run(layout=b.build())
|
|
|
471
584
|
|
|
472
585
|
`@app.status` sets schema / accessible `role="status"` metadata. It is **not** an ARIA live region yet (planned for a later release). Prefer it for operation feedback labels; use `@app.label` for static or high-frequency mirror text.
|
|
473
586
|
|
|
587
|
+
### File picker
|
|
588
|
+
|
|
589
|
+
`@app.filepicker` creates a button that opens a tkinter file dialog.
|
|
590
|
+
The selected path(s) are passed to the callback; `None` on cancel.
|
|
591
|
+
|
|
592
|
+
```python
|
|
593
|
+
@app.filepicker("open_file", mode="open", label="Open file",
|
|
594
|
+
title="Open file", filetypes=[("Text files", "*.txt")])
|
|
595
|
+
def pick_open_file(path: str | None) -> dict[str, Any]:
|
|
596
|
+
return {"open_path": path}
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
Modes: `"open"` (default), `"open_multiple"`, `"save"`, `"directory"`.
|
|
600
|
+
|
|
601
|
+
A filepicker can also be invoked from a menubar item by using its name
|
|
602
|
+
as the `command`:
|
|
603
|
+
|
|
604
|
+
```python
|
|
605
|
+
@app.filepicker("m_open", mode="open", title="Open file")
|
|
606
|
+
def m_open(path):
|
|
607
|
+
return {"open_path": path}
|
|
608
|
+
|
|
609
|
+
@app.menubar("menu")
|
|
610
|
+
def menu_bar():
|
|
611
|
+
return [{"label": "File", "items": [
|
|
612
|
+
{"label": "Open...", "command": "m_open"},
|
|
613
|
+
]}]
|
|
614
|
+
```
|
|
615
|
+
|
|
474
616
|
`app.run(stages=..., tabposition=...)` and `@app.stages` provide state-driven screen switching (one visible stage at a time). Theme helpers (`apply_theme`, `tokens`, layout chrome) ship in the package root — see `examples/header_demo.py`.
|
|
475
617
|
|
|
476
618
|
Common options:
|
|
@@ -572,7 +714,9 @@ uv run python examples/widget_gallery.py # all widget types
|
|
|
572
714
|
uv run python examples/header_demo.py # Layout.header / .status chrome
|
|
573
715
|
uv run python examples/combobox_demo.py # ttk.Combobox
|
|
574
716
|
uv run python examples/menubar_demo.py # menubar
|
|
717
|
+
uv run python examples/filepicker_demo.py # file picker
|
|
575
718
|
uv run python examples/disk_usage_flat_async.py # ncdu-style viewer (async)
|
|
719
|
+
uv run python examples/paired_demo.py # side-by-side paired layout with y-scroll sync
|
|
576
720
|
```
|
|
577
721
|
|
|
578
722
|
---
|
|
@@ -130,7 +130,18 @@ PyPI: Trusted Publishing on `v*` tag push (`.github/workflows/publish.yml`).
|
|
|
130
130
|
|
|
131
131
|
---
|
|
132
132
|
|
|
133
|
-
##
|
|
133
|
+
## Snapshot (v0.4.3) — filepicker + layout blocks
|
|
134
|
+
|
|
135
|
+
- Added `@app.filepicker` (`filedialog` wrapper; `mode=` open/save/directory/open_multiple).
|
|
136
|
+
- Added `Layout(spacing=...)` for a layout-wide SPACE token default (padx/pady inheritance).
|
|
137
|
+
- Added `Layout.frame(...)` for nested layouts (`Layout` inside `Layout`).
|
|
138
|
+
- Added `Layout.cluster(...)` wrapping flow: widgets keep content/`width=` sizes and wrap left-to-right.
|
|
139
|
+
- Added `Layout.paired(left, right, ..., sync_yscroll=)` for side-by-side panes with optional text y-scroll sync.
|
|
140
|
+
- Docs/examples/tests for the above (`filepicker_demo`, `nested_frame_demo`, `cluster_demo`, `paired_demo`).
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Near term (post-0.4.3)
|
|
134
145
|
|
|
135
146
|
### A11y
|
|
136
147
|
|
|
@@ -149,19 +160,21 @@ PyPI: Trusted Publishing on `v*` tag push (`.github/workflows/publish.yml`).
|
|
|
149
160
|
- [x] Per-widget `bind` for entry and listbox (`events=` on `@app.entry` and `@app.listbox`)
|
|
150
161
|
- [ ] Per-widget `bind` for other widgets (`text`, `combobox`, `treeview`, `button`, etc.)
|
|
151
162
|
- [x] listbox callback value design: keep selection index (unify with `treeview`)
|
|
163
|
+
- [x] `@app.filepicker` — `filedialog` wrapper (schema/tool naming still open)
|
|
152
164
|
|
|
153
165
|
### Layout DSL
|
|
154
166
|
|
|
155
167
|
- [x] Deprecate index-order plural methods `col_weights`/`row_weights`/`col_minsizes`/`row_minsizes` in 0.4.1 (remove in 0.5.0).
|
|
156
168
|
- [ ] CSS-grid-inspired layout API for 0.5.0: explicit `grid-template-columns` / `grid-template-rows` strings (e.g. `"1fr 2fr"`, `"auto 1fr"`), area-based placement, and gap tokens. Reduce misuse by making rows/columns and widget placement visually aligned in one declaration.
|
|
157
|
-
- [
|
|
158
|
-
- [
|
|
169
|
+
- [x] Nested frames (`Layout` inside `Layout`) via `Layout.frame(...)`
|
|
170
|
+
- [x] Global default for `padx`/`pady` via `Layout(spacing=...)`
|
|
171
|
+
- [x] `Layout.cluster(...)` wrapping flow; `Layout.paired(..., sync_yscroll=)`
|
|
159
172
|
|
|
160
173
|
### Agent / LLM integration
|
|
161
174
|
|
|
162
175
|
- [ ] Expose `schema()` as Function Calling definition (bind `sequence`, treeview column defs, etc.)
|
|
163
176
|
- [ ] `@agent_tool` integration (GUI operations as agent vocabulary)
|
|
164
|
-
- [ ] `@app.filepicker`
|
|
177
|
+
- [ ] Surface `@app.filepicker` in `schema()` / tool vocabulary
|
|
165
178
|
|
|
166
179
|
---
|
|
167
180
|
|
|
@@ -37,6 +37,8 @@ from nextpytk.types import (
|
|
|
37
37
|
ComboboxOptions,
|
|
38
38
|
EntryOptions,
|
|
39
39
|
EventSeq,
|
|
40
|
+
FilepickerCallback,
|
|
41
|
+
FilepickerOptions,
|
|
40
42
|
FillLike,
|
|
41
43
|
LabelOptions,
|
|
42
44
|
EntryEventHandler,
|
|
@@ -103,6 +105,9 @@ ButtonCallback = (
|
|
|
103
105
|
)
|
|
104
106
|
BindCallback = ButtonCallback # bind: same signature as button
|
|
105
107
|
|
|
108
|
+
# filepicker: receives selected path(s) or None, returns state dict
|
|
109
|
+
FilepickerCallback = Callable[[str | list[str] | None], dict[str, Any]]
|
|
110
|
+
|
|
106
111
|
# entry / text / scale / spinbox: receives value str (optional), returns state dict
|
|
107
112
|
ValueCallback = Callable[[str], dict[str, Any]] | Callable[[], dict[str, Any]]
|
|
108
113
|
|
|
@@ -186,7 +191,7 @@ class ViewContext:
|
|
|
186
191
|
"label", "status", "message", "button", "bind", "entry",
|
|
187
192
|
"checkbutton", "radiobutton", "text", "scale", "spinbox",
|
|
188
193
|
"listbox", "combobox", "treeview", "paned", "progressbar", "canvas",
|
|
189
|
-
"menubar",
|
|
194
|
+
"menubar", "filepicker",
|
|
190
195
|
})
|
|
191
196
|
|
|
192
197
|
def pane(self, pane_id: str) -> _PaneContext:
|
|
@@ -248,6 +253,7 @@ class TkApp:
|
|
|
248
253
|
self._treeview_inner: dict[str, ttk.Treeview] = {}
|
|
249
254
|
self._treeview_row_cache: dict[str, tuple[Any, ...]] = {}
|
|
250
255
|
self._text_inner: dict[str, tk.Text] = {}
|
|
256
|
+
self._text_scrollbars: dict[str, ttk.Scrollbar] = {}
|
|
251
257
|
self._text_scroll_sync: dict[str, str] = {}
|
|
252
258
|
self._paned_inner: dict[str, ttk.Panedwindow] = {}
|
|
253
259
|
self._pane_frames: dict[str, tk.Widget] = {}
|
|
@@ -295,6 +301,7 @@ class TkApp:
|
|
|
295
301
|
self._grid_pack_jobs.clear()
|
|
296
302
|
self._menubar_submenus.clear()
|
|
297
303
|
self._text_inner.clear()
|
|
304
|
+
self._text_scrollbars.clear()
|
|
298
305
|
self._text_scroll_sync.clear()
|
|
299
306
|
self._a11y_last_toggle.clear()
|
|
300
307
|
self._current_view = None
|
|
@@ -1463,6 +1470,9 @@ class TkApp:
|
|
|
1463
1470
|
spec = self._spec(command_name)
|
|
1464
1471
|
if spec is None or spec.on_click is None:
|
|
1465
1472
|
return
|
|
1473
|
+
if spec.kind == "filepicker":
|
|
1474
|
+
self._invoke_filepicker(spec)
|
|
1475
|
+
return
|
|
1466
1476
|
values = self._entry_values_dict()
|
|
1467
1477
|
self._state[command_name] = label
|
|
1468
1478
|
result = self._dispatch(command_name, spec.on_click, values)
|
|
@@ -1631,6 +1641,60 @@ class TkApp:
|
|
|
1631
1641
|
return fn
|
|
1632
1642
|
return decorator
|
|
1633
1643
|
|
|
1644
|
+
def filepicker(
|
|
1645
|
+
self,
|
|
1646
|
+
name: str,
|
|
1647
|
+
**options: Unpack[FilepickerOptions],
|
|
1648
|
+
) -> Callable[[Callable[[Any], dict[str, Any]]], Callable[[Any], dict[str, Any]]]:
|
|
1649
|
+
"""Register a file picker button.
|
|
1650
|
+
|
|
1651
|
+
Clicking the button opens a tkinter file dialog. The selected path(s)
|
|
1652
|
+
are passed to the decorated callback, which returns a state update
|
|
1653
|
+
dict. ``mode`` selects the dialog type:
|
|
1654
|
+
|
|
1655
|
+
- ``"open"`` (default): single file open via ``askopenfilename``.
|
|
1656
|
+
- ``"open_multiple"``: multiple files via ``askopenfilenames``.
|
|
1657
|
+
- ``"save"``: save file via ``asksaveasfilename``.
|
|
1658
|
+
- ``"directory"``: directory via ``askdirectory``.
|
|
1659
|
+
|
|
1660
|
+
``title``, ``initialdir``, ``initialfile``, ``filetypes``, and
|
|
1661
|
+
``defaultextension`` are forwarded to the underlying dialog. For
|
|
1662
|
+
``filetypes`` use a sequence of ``("label", "*.ext")`` or
|
|
1663
|
+
``("label", "*.ext1 *.ext2")`` tuples.
|
|
1664
|
+
|
|
1665
|
+
The callback receives the selected path string, a list of strings for
|
|
1666
|
+
``"open_multiple"``, or ``None`` when the user cancels.
|
|
1667
|
+
|
|
1668
|
+
Example::
|
|
1669
|
+
|
|
1670
|
+
@app.filepicker("open_path", mode="open", title="Open file")
|
|
1671
|
+
def pick_open_path(path: str | None) -> dict[str, Any]:
|
|
1672
|
+
return {"open_path": path}
|
|
1673
|
+
"""
|
|
1674
|
+
def decorator(fn: Callable[[Any], dict[str, Any]]) -> Callable[[Any], dict[str, Any]]:
|
|
1675
|
+
mode = options.get("mode", "open")
|
|
1676
|
+
label = options.get("label", "")
|
|
1677
|
+
description = options.get("description")
|
|
1678
|
+
enabled_if = options.get("enabled_if")
|
|
1679
|
+
takefocus = options.get("takefocus")
|
|
1680
|
+
primary = options.get("primary", False)
|
|
1681
|
+
font = options.get("font")
|
|
1682
|
+
extras: dict[str, Any] = {
|
|
1683
|
+
"mode": mode,
|
|
1684
|
+
"style": "Primary.TButton" if primary else "Secondary.TButton",
|
|
1685
|
+
}
|
|
1686
|
+
for opt in ("title", "initialdir", "initialfile", "filetypes", "defaultextension"):
|
|
1687
|
+
value = options.get(opt)
|
|
1688
|
+
if value is not None:
|
|
1689
|
+
extras[opt] = value
|
|
1690
|
+
self._add_spec(WidgetSpec(
|
|
1691
|
+
name=name, kind="filepicker", label_text=label, role="button",
|
|
1692
|
+
description=description, on_click=fn, enabled_if=enabled_if,
|
|
1693
|
+
extras=self._widget_extras(extras, takefocus=takefocus),
|
|
1694
|
+
))
|
|
1695
|
+
return fn
|
|
1696
|
+
return decorator
|
|
1697
|
+
|
|
1634
1698
|
def button(
|
|
1635
1699
|
self,
|
|
1636
1700
|
name: str,
|
|
@@ -2461,7 +2525,9 @@ class TkApp:
|
|
|
2461
2525
|
value = result
|
|
2462
2526
|
elif isinstance(result, dict):
|
|
2463
2527
|
value = result.get(spec.name, value)
|
|
2464
|
-
|
|
2528
|
+
target = str(value)
|
|
2529
|
+
if str(tk_w.cget("text")) != target:
|
|
2530
|
+
tk_w.configure(text=target) # type: ignore[call-arg]
|
|
2465
2531
|
elif spec.kind == "text":
|
|
2466
2532
|
self._sync_text_widget(spec)
|
|
2467
2533
|
elif spec.kind == "listbox" and spec.extras.get("items_key") is not None:
|
|
@@ -2535,7 +2601,9 @@ class TkApp:
|
|
|
2535
2601
|
tk_w = self._tk_widgets.get(spec.name)
|
|
2536
2602
|
if tk_w is None:
|
|
2537
2603
|
continue
|
|
2538
|
-
|
|
2604
|
+
target = str(update[spec.name])
|
|
2605
|
+
if str(tk_w.cget("text")) != target:
|
|
2606
|
+
tk_w.configure(text=target) # type: ignore[call-arg]
|
|
2539
2607
|
elif spec.kind == "text" and spec.name in update:
|
|
2540
2608
|
self._sync_text_widget(spec)
|
|
2541
2609
|
|
|
@@ -2816,6 +2884,7 @@ class TkApp:
|
|
|
2816
2884
|
"label": self._build_label,
|
|
2817
2885
|
"message": self._build_message,
|
|
2818
2886
|
"button": self._build_button,
|
|
2887
|
+
"filepicker": self._build_filepicker,
|
|
2819
2888
|
"entry": self._build_entry,
|
|
2820
2889
|
"checkbutton": self._build_checkbutton,
|
|
2821
2890
|
"radiobutton": self._build_radiobutton,
|
|
@@ -2956,11 +3025,28 @@ class TkApp:
|
|
|
2956
3025
|
if text_a is None or text_b is None:
|
|
2957
3026
|
continue
|
|
2958
3027
|
|
|
2959
|
-
|
|
2960
|
-
|
|
3028
|
+
sb_a = self._text_scrollbars.get(name_a)
|
|
3029
|
+
sb_b = self._text_scrollbars.get(name_b)
|
|
2961
3030
|
|
|
2962
|
-
def
|
|
3031
|
+
def _sync_from_a(
|
|
3032
|
+
*args: Any,
|
|
3033
|
+
source: tk.Text = text_a,
|
|
3034
|
+
target: tk.Text = text_b,
|
|
3035
|
+
sb: ttk.Scrollbar | None = sb_a,
|
|
3036
|
+
) -> None:
|
|
2963
3037
|
target.yview_moveto(source.yview()[0])
|
|
3038
|
+
if sb is not None:
|
|
3039
|
+
sb.set(*args)
|
|
3040
|
+
|
|
3041
|
+
def _sync_from_b(
|
|
3042
|
+
*args: Any,
|
|
3043
|
+
source: tk.Text = text_b,
|
|
3044
|
+
target: tk.Text = text_a,
|
|
3045
|
+
sb: ttk.Scrollbar | None = sb_b,
|
|
3046
|
+
) -> None:
|
|
3047
|
+
target.yview_moveto(source.yview()[0])
|
|
3048
|
+
if sb is not None:
|
|
3049
|
+
sb.set(*args)
|
|
2964
3050
|
|
|
2965
3051
|
text_a.configure(yscrollcommand=_sync_from_a)
|
|
2966
3052
|
text_b.configure(yscrollcommand=_sync_from_b)
|
|
@@ -3176,6 +3262,51 @@ class TkApp:
|
|
|
3176
3262
|
fn = spec.on_click
|
|
3177
3263
|
w.configure(command=lambda s=spec, f=fn: self._on_button_click(s, f))
|
|
3178
3264
|
|
|
3265
|
+
def _invoke_filepicker(self, spec: WidgetSpec) -> None:
|
|
3266
|
+
"""Open the file dialog for a filepicker spec and dispatch the result."""
|
|
3267
|
+
import tkinter.filedialog as fd
|
|
3268
|
+
|
|
3269
|
+
mode = spec.extras.get("mode", "open")
|
|
3270
|
+
dialog_kw: dict[str, Any] = {}
|
|
3271
|
+
for opt in ("title", "initialdir", "initialfile", "filetypes", "defaultextension"):
|
|
3272
|
+
if opt in spec.extras:
|
|
3273
|
+
dialog_kw[opt] = spec.extras[opt]
|
|
3274
|
+
|
|
3275
|
+
if mode == "open":
|
|
3276
|
+
result = fd.askopenfilename(**dialog_kw)
|
|
3277
|
+
elif mode == "open_multiple":
|
|
3278
|
+
result = fd.askopenfilenames(**dialog_kw)
|
|
3279
|
+
elif mode == "save":
|
|
3280
|
+
result = fd.asksaveasfilename(**dialog_kw)
|
|
3281
|
+
elif mode == "directory":
|
|
3282
|
+
result = fd.askdirectory(**dialog_kw)
|
|
3283
|
+
else:
|
|
3284
|
+
raise ValueError(f"unknown filepicker mode {mode!r}")
|
|
3285
|
+
if result == "" or result == () or result is None:
|
|
3286
|
+
result = None
|
|
3287
|
+
fn = spec.on_click
|
|
3288
|
+
if fn is None:
|
|
3289
|
+
return
|
|
3290
|
+
out = self._dispatch(spec.name, fn, result)
|
|
3291
|
+
if isinstance(out, dict) and out:
|
|
3292
|
+
self._apply_state(out)
|
|
3293
|
+
|
|
3294
|
+
def _build_filepicker(self, spec: WidgetSpec, master: tk.Misc) -> None:
|
|
3295
|
+
"""Build a button that opens a file dialog when clicked."""
|
|
3296
|
+
style = spec.extras.get("style", "Secondary.TButton")
|
|
3297
|
+
overrides: dict[str, Any] = {}
|
|
3298
|
+
if "font" in spec.extras:
|
|
3299
|
+
overrides["font"] = spec.extras["font"]
|
|
3300
|
+
if overrides:
|
|
3301
|
+
style = self._derive_ttk_style(
|
|
3302
|
+
style, f"Unique.{style}.{spec.name}", overrides
|
|
3303
|
+
)
|
|
3304
|
+
w = ttk.Button(master, text=spec.label_text or spec.name, style=style)
|
|
3305
|
+
if "state" in spec.extras:
|
|
3306
|
+
w.configure(state=spec.extras["state"])
|
|
3307
|
+
self._tk_widgets[spec.name] = w
|
|
3308
|
+
w.configure(command=lambda s=spec: self._invoke_filepicker(s))
|
|
3309
|
+
|
|
3179
3310
|
def _build_entry(self, spec: WidgetSpec, master: tk.Misc) -> None:
|
|
3180
3311
|
e = spec.extras
|
|
3181
3312
|
var = tk.StringVar(value="")
|
|
@@ -3301,6 +3432,7 @@ class TkApp:
|
|
|
3301
3432
|
scroll.grid(row=0, column=1, sticky="ns")
|
|
3302
3433
|
self._tk_widgets[spec.name] = container
|
|
3303
3434
|
self._text_inner[spec.name] = w
|
|
3435
|
+
self._text_scrollbars[spec.name] = scroll
|
|
3304
3436
|
|
|
3305
3437
|
tags: dict[str, dict[str, Any]] | None = e.get("tags")
|
|
3306
3438
|
if tags:
|
|
@@ -3972,6 +4104,11 @@ class TkApp:
|
|
|
3972
4104
|
d["value"] = self._state.get(sk, 0)
|
|
3973
4105
|
if w.kind == "entry":
|
|
3974
4106
|
d["placeholder_as_hint"] = w.placeholder_as_hint
|
|
4107
|
+
if w.kind == "filepicker":
|
|
4108
|
+
d["mode"] = w.extras.get("mode", "open")
|
|
4109
|
+
for opt in ("title", "initialdir", "initialfile", "filetypes", "defaultextension"):
|
|
4110
|
+
if opt in w.extras:
|
|
4111
|
+
d[opt] = w.extras[opt]
|
|
3975
4112
|
if w.kind == "menubar":
|
|
3976
4113
|
d["items"] = [
|
|
3977
4114
|
{"label": i.get("label"), "command": i.get("command"),
|