nextpytk 0.4.2__tar.gz → 0.4.4__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.
@@ -1,4 +1,5 @@
1
1
  .venv/
2
2
  __pycache__/
3
3
  dist/
4
- *.egg-info/
4
+ *.egg-info/
5
+ .DS_Store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextpytk
3
- Version: 0.4.2
3
+ Version: 0.4.4
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
@@ -212,6 +212,7 @@ Layout().section("msg", side=Side.LEFT, fill=Fill.X)
212
212
  | `Orient` | `Orient.HORIZONTAL/VERTICAL` | scale / paned orientation |
213
213
  | `Relief` | `Relief.FLAT/RAISED/SUNKEN/...` | border style |
214
214
  | `Justify` | `Justify.LEFT/RIGHT/CENTER` | text alignment |
215
+ | `Wrap` | `Wrap.WORD/NONE/CHAR` | text wrap mode |
215
216
  | `SelectMode` | `SelectMode.SINGLE/BROWSE/MULTIPLE/EXTENDED` | listbox mode |
216
217
  | `EventSeq` | `EventSeq.RETURN/ESCAPE/BACKSPACE/DELETE/TAB/...` | event sequences for bindings |
217
218
  | `EventSeq` (mouse) | `EventSeq.BUTTON_1/2/3`, `DOUBLE_BUTTON_1/2/3`, `PRIMARY_DOUBLE_CLICK` | mouse event sequences |
@@ -400,6 +401,155 @@ Each name gets its own pack-based section. Extra kwargs forwarded to `section()`
400
401
  Layout.from_list(["a", "b"], fill="both", expand=True)
401
402
  ```
402
403
 
404
+ ### Layout spacing
405
+
406
+ The default block padding comes from the design token `SPACE[1]` (4 px). Pass
407
+ `spacing=...` to a `Layout` constructor to change the default padding for every
408
+ block it creates:
409
+
410
+ ```python
411
+ from nextpytk import tokens
412
+
413
+ layout = Layout(spacing=2).section("a").section("b").grid().widget("c").end_grid()
414
+ ```
415
+
416
+ `spacing` accepts a key from `nextpytk.tokens.SPACE` (1, 2, 3, 4, 6, 8). It sets
417
+ the default `padx` and `pady` used by `section()`, `grid()`, `paned()`, grid
418
+ `widget()` placements, and nested frames. Explicit `padx`/`pady` arguments still
419
+ override the default.
420
+
421
+ For finer control, pass `padx` or `pady` directly to the constructor:
422
+
423
+ ```python
424
+ Layout(padx=tokens.SPACE[4], pady=tokens.SPACE[3]).section("a")
425
+ ```
426
+
427
+ ### Nested frames
428
+
429
+ A `Layout` can be placed inside a named frame to group widgets visually without
430
+ mixing pack and grid in the same parent. The inner layout owns its own spacing
431
+ and may use any layout style (pack sections, grid, paned, or further nested
432
+ frames).
433
+
434
+ ```python
435
+ inner = Layout().section("a", "b")
436
+ outer = Layout().section("title").frame("group", inner).section("ok")
437
+ app.run(layout=outer)
438
+ ```
439
+
440
+ `frame(name, layout, side=..., fill=..., expand=..., padx=..., pady=...)` packs
441
+ the inner layout as a single block. The frame itself is registered under `name`,
442
+ so it can also be placed inside a grid cell:
443
+
444
+ ```python
445
+ outer = (
446
+ Layout()
447
+ .grid()
448
+ .widget("label")
449
+ .widget("group", sticky="nsew")
450
+ .end_grid()
451
+ .frame("group", Layout().section("a", "b"))
452
+ )
453
+ ```
454
+
455
+ ### Paired layout
456
+
457
+ `Layout.paired(left, right, ...)` places two widgets side by side in a
458
+ single two-column frame. It is a lighter alternative to `app.paned` for
459
+ diff/compare views:
460
+
461
+ ```python
462
+ layout = (
463
+ Layout()
464
+ .section("info")
465
+ .paired(
466
+ "left_text",
467
+ "right_text",
468
+ weight=(1, 1),
469
+ fill=Fill.BOTH,
470
+ expand=True,
471
+ sync_yscroll=True,
472
+ )
473
+ )
474
+ ```
475
+
476
+ Options:
477
+
478
+ | Argument | Default | Description |
479
+ |----------|---------|-------------|
480
+ | `weight` | `(1, 1)` | Two-column `(left, right)` grid weights |
481
+ | `fill` | `"x"` | Frame `fill` passed to `pack` |
482
+ | `expand` | `False` | Frame `expand` passed to `pack` |
483
+ | `sync_yscroll` | `False` | Wire the two widgets' y-scroll commands together |
484
+ | `line_numbers` | `False` | Add read-only line-number gutters to both sides |
485
+ | `side`, `padx`, `pady`, `anchor` | — | Standard `Layout` block placement options |
486
+
487
+ When `sync_yscroll=True` and both widgets are `@app.text` widgets, scrolling
488
+ either pane moves the other. For text widgets the same effect can also be
489
+ achieved per-widget with `@app.text(..., sync_yscroll_with="other")`; paired
490
+ layout provides a layout-level switch that works without editing widget
491
+ declarations.
492
+
493
+ With `line_numbers=True` each side gains a read-only line-number gutter. Both
494
+ panes and both gutters share a **single vertical scrollbar**, so the gutters
495
+ stay in lock-step with the content (no drift). Logical line numbers
496
+ (`1..n`) follow the pane content; the right pane's numbers update as you edit.
497
+
498
+
499
+ ### Cluster layout
500
+
501
+ `Layout.cluster()` is a wrapping flow: widgets are not stretched to fill a
502
+ column; each keeps the width implied by its content or `width=` and they lay
503
+ out left to right, wrapping to the next row whenever the next widget no longer
504
+ fits in the remaining frame width. Widgets in the same row are vertically
505
+ centered on their midline, so a tall `entry` aligns cleanly with a shorter
506
+ `button` or `checkbutton`. The gap inherits the layout spacing by default.
507
+
508
+ ```python
509
+ TAGS = ["python", "tkinter", "async", "uv", "type hints"]
510
+ for tag in TAGS:
511
+ @app.button(tag, label=f"#{tag}")
512
+ def on_tag(values, tag=tag):
513
+ return {"msg": f"Selected: {tag}"}
514
+
515
+ app.run(layout=Layout(spacing=2).status("msg").cluster(*TAGS))
516
+ ```
517
+
518
+ Cluster is ideal for tag clouds, toolbars, and filter UIs. The layout recomputes
519
+ rows automatically when the window is resized. Pass `gap=...` to override the
520
+ spacing default, and `side`/`fill`/`expand` to control how the cluster frame is
521
+ packed.
522
+
523
+ ### Dynamic region switching (swap targets)
524
+
525
+ `Layout.target()` reserves a region whose contents change at runtime, mirroring
526
+ HTMX `hx-target` / `hx-swap`. Surrounding sections (toolbar, status) stay fixed
527
+ while only the target region swaps:
528
+
529
+ ```python
530
+ layout = Layout().cluster("go_dir", "go_file", "info").target("main_area")
531
+
532
+ @app.swap(
533
+ "main_area",
534
+ variants={
535
+ "dir": [Layout().section("dir_tree")],
536
+ "file": [Layout().paired("left_text", "right_text",
537
+ fill=Fill.BOTH, expand=True)],
538
+ },
539
+ default="dir",
540
+ )
541
+ def main_area():
542
+ pass
543
+
544
+ # Switch imperatively at runtime:
545
+ app.swap_view("main_area", "file")
546
+ ```
547
+
548
+ `@app.swap` variants are mounted up-front and shown/hidden via pack, so widget
549
+ state (e.g. a treeview selection or scroll position) survives switching. Each
550
+ variant is a `Layout` or a list of widget names. See
551
+ `examples/swap_demo.py`.
552
+
403
553
  ### Fluent DSL
404
554
 
405
555
  **Pack sections:**
@@ -487,7 +637,8 @@ app.run(layout=b.build())
487
637
  | `@app.radiobutton(name, text=..., value=..., group=..., font=...)` | ttk.Radiobutton | selected value `str` | `dict` |
488
638
  | `@app.combobox(name, values=..., values_key=..., readonly=..., font=...)` | ttk.Combobox | selected value `str` | `dict` |
489
639
  | `@app.menubar(name)` | tk.Menu (window menubar) | — | menu item list |
490
- | `@app.text(name, width=..., height=..., font=...)` | tk.Text | full content `str` | `dict` |
640
+ | `@app.filepicker(name, mode=..., label=..., title=..., filetypes=..., ...)` | ttk.Button → tkinter.filedialog | selected path(s) `str`, `list[str]`, or `None` | `dict` |
641
+ | `@app.text(name, width=..., height=..., font=..., wrap=..., h_scroll=...)` | tk.Text | full content `str` | `dict` |
491
642
  | `@app.scale(name, from_=..., to=..., orient=...)` | ttk.Scale | value `str` | `dict` |
492
643
  | `@app.spinbox(name, from_=..., to=..., values=..., font=...)` | ttk.Spinbox | value `str` | `dict` |
493
644
  | `@app.listbox(name, items=..., items_key=..., selectmode=..., font=..., events=...)` | tk.Listbox | selected index `int` (`-1` if none) | `dict` |
@@ -495,6 +646,35 @@ app.run(layout=b.build())
495
646
 
496
647
  `@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
648
 
649
+ ### File picker
650
+
651
+ `@app.filepicker` creates a button that opens a tkinter file dialog.
652
+ The selected path(s) are passed to the callback; `None` on cancel.
653
+
654
+ ```python
655
+ @app.filepicker("open_file", mode="open", label="Open file",
656
+ title="Open file", filetypes=[("Text files", "*.txt")])
657
+ def pick_open_file(path: str | None) -> dict[str, Any]:
658
+ return {"open_path": path}
659
+ ```
660
+
661
+ Modes: `"open"` (default), `"open_multiple"`, `"save"`, `"directory"`.
662
+
663
+ A filepicker can also be invoked from a menubar item by using its name
664
+ as the `command`:
665
+
666
+ ```python
667
+ @app.filepicker("m_open", mode="open", title="Open file")
668
+ def m_open(path):
669
+ return {"open_path": path}
670
+
671
+ @app.menubar("menu")
672
+ def menu_bar():
673
+ return [{"label": "File", "items": [
674
+ {"label": "Open...", "command": "m_open"},
675
+ ]}]
676
+ ```
677
+
498
678
  `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
679
 
500
680
  Common options:
@@ -509,6 +689,32 @@ Entry options: `placeholder`, `show`, `font`, `padding`, `width`, `state`.
509
689
  Button, checkbutton, radiobutton, spinbox, combobox, listbox, text options:
510
690
  - `font` is accepted uniformly. Under the hood, ttk widgets use a derived style so the rest of the theme (colors, maps, layout) is inherited.
511
691
 
692
+ Text options (continued):
693
+ - `wrap`: `Wrap.WORD` (default) / `Wrap.NONE` / `Wrap.CHAR`. `Wrap.NONE` keeps each logical line on a single row.
694
+ - `h_scroll`: when `True`, a horizontal scrollbar is added (and wired to `xscrollcommand`) so long lines are reachable, typically used with `wrap=Wrap.NONE`.
695
+
696
+ Every widget decorator also accepts `widget_kwargs: dict` for per-widget
697
+ design-token overrides applied after construction. Keys are widget-native
698
+ tk/ttk options (`padx`, `pady`, `bg`, `fg`, `font`, …). An invalid/unknown
699
+ key is ignored rather than aborting the build:
700
+
701
+ ```python
702
+ @app.text("log", wrap="none", h_scroll=True,
703
+ widget_kwargs={"bg": "#1e1e1e", "fg": "#dcdcdc"})
704
+ def log(value): return {}
705
+ ```
706
+
707
+ Runtime access: `app.text_widget(name)` returns the real `tk.Text`;
708
+ `app.layout_frame(name)` returns the layout section frame that owns a
709
+ widget (for manual grid/pack placement or re-parenting).
710
+ `app.on_text_set(name, hook)` registers a callback to run after a text
711
+ widget's content is replaced (used by paired line-number gutters).
712
+
713
+ Enum-like options (`wrap`, `state`, `orient`, `selectmode`, `mode`) are
714
+ validated at registration time: an invalid value raises a clear `ValueError`
715
+ naming the option and the allowed values, instead of failing later with a
716
+ `TclError` when the widget is built.
717
+
512
718
  `@app.message` creates an auto-wrapping label. `width` sets initial pixel width; `auto_width=True` (default) tracks parent container resize.
513
719
 
514
720
  ---
@@ -596,7 +802,10 @@ uv run python examples/widget_gallery.py # all widget types
596
802
  uv run python examples/header_demo.py # Layout.header / .status chrome
597
803
  uv run python examples/combobox_demo.py # ttk.Combobox
598
804
  uv run python examples/menubar_demo.py # menubar
805
+ uv run python examples/filepicker_demo.py # file picker
599
806
  uv run python examples/disk_usage_flat_async.py # ncdu-style viewer (async)
807
+ uv run python examples/paired_demo.py # side-by-side paired layout with y-scroll sync
808
+ uv run python examples/swap_demo.py # dynamic region switching (Layout.target + @app.swap)
600
809
  ```
601
810
 
602
811
  ---
@@ -188,6 +188,7 @@ Layout().section("msg", side=Side.LEFT, fill=Fill.X)
188
188
  | `Orient` | `Orient.HORIZONTAL/VERTICAL` | scale / paned orientation |
189
189
  | `Relief` | `Relief.FLAT/RAISED/SUNKEN/...` | border style |
190
190
  | `Justify` | `Justify.LEFT/RIGHT/CENTER` | text alignment |
191
+ | `Wrap` | `Wrap.WORD/NONE/CHAR` | text wrap mode |
191
192
  | `SelectMode` | `SelectMode.SINGLE/BROWSE/MULTIPLE/EXTENDED` | listbox mode |
192
193
  | `EventSeq` | `EventSeq.RETURN/ESCAPE/BACKSPACE/DELETE/TAB/...` | event sequences for bindings |
193
194
  | `EventSeq` (mouse) | `EventSeq.BUTTON_1/2/3`, `DOUBLE_BUTTON_1/2/3`, `PRIMARY_DOUBLE_CLICK` | mouse event sequences |
@@ -376,6 +377,155 @@ Each name gets its own pack-based section. Extra kwargs forwarded to `section()`
376
377
  Layout.from_list(["a", "b"], fill="both", expand=True)
377
378
  ```
378
379
 
380
+ ### Layout spacing
381
+
382
+ The default block padding comes from the design token `SPACE[1]` (4 px). Pass
383
+ `spacing=...` to a `Layout` constructor to change the default padding for every
384
+ block it creates:
385
+
386
+ ```python
387
+ from nextpytk import tokens
388
+
389
+ layout = Layout(spacing=2).section("a").section("b").grid().widget("c").end_grid()
390
+ ```
391
+
392
+ `spacing` accepts a key from `nextpytk.tokens.SPACE` (1, 2, 3, 4, 6, 8). It sets
393
+ the default `padx` and `pady` used by `section()`, `grid()`, `paned()`, grid
394
+ `widget()` placements, and nested frames. Explicit `padx`/`pady` arguments still
395
+ override the default.
396
+
397
+ For finer control, pass `padx` or `pady` directly to the constructor:
398
+
399
+ ```python
400
+ Layout(padx=tokens.SPACE[4], pady=tokens.SPACE[3]).section("a")
401
+ ```
402
+
403
+ ### Nested frames
404
+
405
+ A `Layout` can be placed inside a named frame to group widgets visually without
406
+ mixing pack and grid in the same parent. The inner layout owns its own spacing
407
+ and may use any layout style (pack sections, grid, paned, or further nested
408
+ frames).
409
+
410
+ ```python
411
+ inner = Layout().section("a", "b")
412
+ outer = Layout().section("title").frame("group", inner).section("ok")
413
+ app.run(layout=outer)
414
+ ```
415
+
416
+ `frame(name, layout, side=..., fill=..., expand=..., padx=..., pady=...)` packs
417
+ the inner layout as a single block. The frame itself is registered under `name`,
418
+ so it can also be placed inside a grid cell:
419
+
420
+ ```python
421
+ outer = (
422
+ Layout()
423
+ .grid()
424
+ .widget("label")
425
+ .widget("group", sticky="nsew")
426
+ .end_grid()
427
+ .frame("group", Layout().section("a", "b"))
428
+ )
429
+ ```
430
+
431
+ ### Paired layout
432
+
433
+ `Layout.paired(left, right, ...)` places two widgets side by side in a
434
+ single two-column frame. It is a lighter alternative to `app.paned` for
435
+ diff/compare views:
436
+
437
+ ```python
438
+ layout = (
439
+ Layout()
440
+ .section("info")
441
+ .paired(
442
+ "left_text",
443
+ "right_text",
444
+ weight=(1, 1),
445
+ fill=Fill.BOTH,
446
+ expand=True,
447
+ sync_yscroll=True,
448
+ )
449
+ )
450
+ ```
451
+
452
+ Options:
453
+
454
+ | Argument | Default | Description |
455
+ |----------|---------|-------------|
456
+ | `weight` | `(1, 1)` | Two-column `(left, right)` grid weights |
457
+ | `fill` | `"x"` | Frame `fill` passed to `pack` |
458
+ | `expand` | `False` | Frame `expand` passed to `pack` |
459
+ | `sync_yscroll` | `False` | Wire the two widgets' y-scroll commands together |
460
+ | `line_numbers` | `False` | Add read-only line-number gutters to both sides |
461
+ | `side`, `padx`, `pady`, `anchor` | — | Standard `Layout` block placement options |
462
+
463
+ When `sync_yscroll=True` and both widgets are `@app.text` widgets, scrolling
464
+ either pane moves the other. For text widgets the same effect can also be
465
+ achieved per-widget with `@app.text(..., sync_yscroll_with="other")`; paired
466
+ layout provides a layout-level switch that works without editing widget
467
+ declarations.
468
+
469
+ With `line_numbers=True` each side gains a read-only line-number gutter. Both
470
+ panes and both gutters share a **single vertical scrollbar**, so the gutters
471
+ stay in lock-step with the content (no drift). Logical line numbers
472
+ (`1..n`) follow the pane content; the right pane's numbers update as you edit.
473
+
474
+
475
+ ### Cluster layout
476
+
477
+ `Layout.cluster()` is a wrapping flow: widgets are not stretched to fill a
478
+ column; each keeps the width implied by its content or `width=` and they lay
479
+ out left to right, wrapping to the next row whenever the next widget no longer
480
+ fits in the remaining frame width. Widgets in the same row are vertically
481
+ centered on their midline, so a tall `entry` aligns cleanly with a shorter
482
+ `button` or `checkbutton`. The gap inherits the layout spacing by default.
483
+
484
+ ```python
485
+ TAGS = ["python", "tkinter", "async", "uv", "type hints"]
486
+ for tag in TAGS:
487
+ @app.button(tag, label=f"#{tag}")
488
+ def on_tag(values, tag=tag):
489
+ return {"msg": f"Selected: {tag}"}
490
+
491
+ app.run(layout=Layout(spacing=2).status("msg").cluster(*TAGS))
492
+ ```
493
+
494
+ Cluster is ideal for tag clouds, toolbars, and filter UIs. The layout recomputes
495
+ rows automatically when the window is resized. Pass `gap=...` to override the
496
+ spacing default, and `side`/`fill`/`expand` to control how the cluster frame is
497
+ packed.
498
+
499
+ ### Dynamic region switching (swap targets)
500
+
501
+ `Layout.target()` reserves a region whose contents change at runtime, mirroring
502
+ HTMX `hx-target` / `hx-swap`. Surrounding sections (toolbar, status) stay fixed
503
+ while only the target region swaps:
504
+
505
+ ```python
506
+ layout = Layout().cluster("go_dir", "go_file", "info").target("main_area")
507
+
508
+ @app.swap(
509
+ "main_area",
510
+ variants={
511
+ "dir": [Layout().section("dir_tree")],
512
+ "file": [Layout().paired("left_text", "right_text",
513
+ fill=Fill.BOTH, expand=True)],
514
+ },
515
+ default="dir",
516
+ )
517
+ def main_area():
518
+ pass
519
+
520
+ # Switch imperatively at runtime:
521
+ app.swap_view("main_area", "file")
522
+ ```
523
+
524
+ `@app.swap` variants are mounted up-front and shown/hidden via pack, so widget
525
+ state (e.g. a treeview selection or scroll position) survives switching. Each
526
+ variant is a `Layout` or a list of widget names. See
527
+ `examples/swap_demo.py`.
528
+
379
529
  ### Fluent DSL
380
530
 
381
531
  **Pack sections:**
@@ -463,7 +613,8 @@ app.run(layout=b.build())
463
613
  | `@app.radiobutton(name, text=..., value=..., group=..., font=...)` | ttk.Radiobutton | selected value `str` | `dict` |
464
614
  | `@app.combobox(name, values=..., values_key=..., readonly=..., font=...)` | ttk.Combobox | selected value `str` | `dict` |
465
615
  | `@app.menubar(name)` | tk.Menu (window menubar) | — | menu item list |
466
- | `@app.text(name, width=..., height=..., font=...)` | tk.Text | full content `str` | `dict` |
616
+ | `@app.filepicker(name, mode=..., label=..., title=..., filetypes=..., ...)` | ttk.Button → tkinter.filedialog | selected path(s) `str`, `list[str]`, or `None` | `dict` |
617
+ | `@app.text(name, width=..., height=..., font=..., wrap=..., h_scroll=...)` | tk.Text | full content `str` | `dict` |
467
618
  | `@app.scale(name, from_=..., to=..., orient=...)` | ttk.Scale | value `str` | `dict` |
468
619
  | `@app.spinbox(name, from_=..., to=..., values=..., font=...)` | ttk.Spinbox | value `str` | `dict` |
469
620
  | `@app.listbox(name, items=..., items_key=..., selectmode=..., font=..., events=...)` | tk.Listbox | selected index `int` (`-1` if none) | `dict` |
@@ -471,6 +622,35 @@ app.run(layout=b.build())
471
622
 
472
623
  `@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
624
 
625
+ ### File picker
626
+
627
+ `@app.filepicker` creates a button that opens a tkinter file dialog.
628
+ The selected path(s) are passed to the callback; `None` on cancel.
629
+
630
+ ```python
631
+ @app.filepicker("open_file", mode="open", label="Open file",
632
+ title="Open file", filetypes=[("Text files", "*.txt")])
633
+ def pick_open_file(path: str | None) -> dict[str, Any]:
634
+ return {"open_path": path}
635
+ ```
636
+
637
+ Modes: `"open"` (default), `"open_multiple"`, `"save"`, `"directory"`.
638
+
639
+ A filepicker can also be invoked from a menubar item by using its name
640
+ as the `command`:
641
+
642
+ ```python
643
+ @app.filepicker("m_open", mode="open", title="Open file")
644
+ def m_open(path):
645
+ return {"open_path": path}
646
+
647
+ @app.menubar("menu")
648
+ def menu_bar():
649
+ return [{"label": "File", "items": [
650
+ {"label": "Open...", "command": "m_open"},
651
+ ]}]
652
+ ```
653
+
474
654
  `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
655
 
476
656
  Common options:
@@ -485,6 +665,32 @@ Entry options: `placeholder`, `show`, `font`, `padding`, `width`, `state`.
485
665
  Button, checkbutton, radiobutton, spinbox, combobox, listbox, text options:
486
666
  - `font` is accepted uniformly. Under the hood, ttk widgets use a derived style so the rest of the theme (colors, maps, layout) is inherited.
487
667
 
668
+ Text options (continued):
669
+ - `wrap`: `Wrap.WORD` (default) / `Wrap.NONE` / `Wrap.CHAR`. `Wrap.NONE` keeps each logical line on a single row.
670
+ - `h_scroll`: when `True`, a horizontal scrollbar is added (and wired to `xscrollcommand`) so long lines are reachable, typically used with `wrap=Wrap.NONE`.
671
+
672
+ Every widget decorator also accepts `widget_kwargs: dict` for per-widget
673
+ design-token overrides applied after construction. Keys are widget-native
674
+ tk/ttk options (`padx`, `pady`, `bg`, `fg`, `font`, …). An invalid/unknown
675
+ key is ignored rather than aborting the build:
676
+
677
+ ```python
678
+ @app.text("log", wrap="none", h_scroll=True,
679
+ widget_kwargs={"bg": "#1e1e1e", "fg": "#dcdcdc"})
680
+ def log(value): return {}
681
+ ```
682
+
683
+ Runtime access: `app.text_widget(name)` returns the real `tk.Text`;
684
+ `app.layout_frame(name)` returns the layout section frame that owns a
685
+ widget (for manual grid/pack placement or re-parenting).
686
+ `app.on_text_set(name, hook)` registers a callback to run after a text
687
+ widget's content is replaced (used by paired line-number gutters).
688
+
689
+ Enum-like options (`wrap`, `state`, `orient`, `selectmode`, `mode`) are
690
+ validated at registration time: an invalid value raises a clear `ValueError`
691
+ naming the option and the allowed values, instead of failing later with a
692
+ `TclError` when the widget is built.
693
+
488
694
  `@app.message` creates an auto-wrapping label. `width` sets initial pixel width; `auto_width=True` (default) tracks parent container resize.
489
695
 
490
696
  ---
@@ -572,7 +778,10 @@ uv run python examples/widget_gallery.py # all widget types
572
778
  uv run python examples/header_demo.py # Layout.header / .status chrome
573
779
  uv run python examples/combobox_demo.py # ttk.Combobox
574
780
  uv run python examples/menubar_demo.py # menubar
781
+ uv run python examples/filepicker_demo.py # file picker
575
782
  uv run python examples/disk_usage_flat_async.py # ncdu-style viewer (async)
783
+ uv run python examples/paired_demo.py # side-by-side paired layout with y-scroll sync
784
+ uv run python examples/swap_demo.py # dynamic region switching (Layout.target + @app.swap)
576
785
  ```
577
786
 
578
787
  ---
@@ -130,7 +130,87 @@ PyPI: Trusted Publishing on `v*` tag push (`.github/workflows/publish.yml`).
130
130
 
131
131
  ---
132
132
 
133
- ## Near term (post-0.4.2)
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
+ ## Snapshot (v0.4.4) — text wrap/h-scroll, public runtime API
145
+
146
+ ### Text widget ergonomics (continued)
147
+
148
+ - Added `wrap=` option to `@app.text` for logical-line display control:
149
+ - `Wrap.WORD` (default) — wrap at word boundaries.
150
+ - `Wrap.NONE` — keep each logical line on one row (requires horizontal scrolling).
151
+ - `Wrap.CHAR` — wrap at any character boundary.
152
+ - Added a new `nextpytk.types.Wrap` typed-constant class and `WrapLike` union.
153
+ - Added `h_scroll=` option to `@app.text`: when True, a horizontal scrollbar is added (and wired to `xscrollcommand`) so long lines are reachable in `Wrap.NONE` mode. The horizontal scrollbar is tracked in `app._text_hscrollbars`.
154
+
155
+ ### Decorator type-definition sync
156
+
157
+ - `TreeviewOptions.double_click` is now typed and honored by `@app.treeview`.
158
+ - Defaults to `True`: the `activate` handler fires on double-click (previous behavior).
159
+ - When `False`, `activate` fires on a single `ButtonRelease-1` instead.
160
+ - `_on_treeview_activate` accepts an optional `event` argument so the single-click binding can reuse it.
161
+
162
+ ### Public runtime API
163
+
164
+ - Added `app.layout_frame(name)` — returns the layout frame that owns a widget (the section frame), so applications can place/re-parent widgets with grid/pack without reaching into the private `_widget_masters`.
165
+ - This is the public replacement for the internal `_widget_masters` access that previously forced applications to depend on private state.
166
+
167
+ ### Per-widget design-token overrides
168
+
169
+ - All widget decorators now accept `widget_kwargs: dict[str, Any]` (on `CommonWidgetOptions`).
170
+ - Keys are widget-native tk/ttk options (`padx`, `pady`, `bg`, `fg`, `font`, …); values are the native values.
171
+ - Overrides are applied after construction by `_apply_widget_overrides()`. An invalid/unknown key raises no error: a `TclError` is swallowed so one bad key does not abort the build.
172
+ - This removes the need to monkey-patch the shared `tokens` module to tweak a single widget's appearance.
173
+
174
+ ### Decorator argument validation
175
+
176
+ - Added `_validate_choice()` / `_validate_positive_int()` helpers used by the decorators to reject invalid enum-like and non-positive-int options at **registration time** (before any Tk widget is built).
177
+ - Validated options:
178
+ - `text`: `state` (`normal`/`disabled`/`active`), `wrap` (`word`/`none`/`char`)
179
+ - `scale`, `progressbar`, `paned`: `orient` (`horizontal`/`vertical`)
180
+ - `listbox`, `treeview`: `selectmode` (`single`/`browse`/`multiple`/`extended`)
181
+ - `filepicker`: `mode` (`open`/`open_multiple`/`save`/`directory`)
182
+ - `progressbar`: `mode` (`determinate`/`indeterminate`)
183
+ - A mistyped value (e.g. `wrap="wrap"`) now raises a clear `ValueError` naming the option, widget, and allowed values — instead of a cryptic `_tkinter.TclError` at runtime.
184
+
185
+ ### Paired line-number gutters
186
+
187
+ - Added `line_numbers=True` to `Layout.paired(...)`.
188
+ - Each side gains a read-only line-number gutter (`disabled` tk.Text, `takefocus=0`, arrow cursor).
189
+ - **Reliable sync via a single shared vertical scrollbar**: both panes and both gutters drive one shared scrollbar. Each widget's `yscrollcommand` is chained so scrolling any widget moves the other three, and the shared scrollbar's `command` drives all four. This avoids the `yview_moveto` drift that plagued per-widget gutter chaining.
190
+ - Gutters stay in sync when content changes via `app.text_set()` (a new `app.on_text_set()` hook) and when the editable right pane is typed into (`<KeyRelease>`).
191
+ - Added `examples/paired_demo.py` demonstrating `line_numbers=True`.
192
+ - The new `app.on_text_set(name, hook)` public helper lets layout features react to programmatic text replacement.
193
+
194
+ ### Dynamic layout switching (swap targets)
195
+
196
+ - Added `Layout.target(name)` to reserve a swap region whose contents change at runtime (mirrors HTMX `hx-target`).
197
+ - Added `@app.swap(name, variants=..., default=...)` to declare the layouts that can fill a target (mirrors HTMX `hx-swap`), and `app.swap_view(name, variant)` to switch imperatively.
198
+ - Variants are mounted up-front and shown/hidden via `pack`/`pack_forget`, so widget state (treeview selection, scroll position) survives switching. Surrounding `Layout.section` blocks (toolbar, status) stay fixed.
199
+ - Added `examples/swap_demo.py` (folder view <-> paired diff view).
200
+
201
+ ### Tests
202
+
203
+ - Added `tests/test_widget_public_api.py` covering `layout_frame` and `widget_kwargs` (applied, ignored-invalid).
204
+ - Added `tests/test_decorator_validation.py` covering invalid-option rejection for every validated widget.
205
+ - Added wrap/h-scroll tests to `tests/test_text.py`.
206
+ - Added `double_click` tests to `tests/test_state.py`.
207
+ - Added paired line-number gutter tests to `tests/test_paired.py` (creation, population, shared-scrollbar sync).
208
+ - Added swap tests to `tests/test_swap.py` (variant registration, build, switching, back-and-forth, pre-build intent).
209
+ - Suite grew from 181 to 208 passing; pyright clean.
210
+
211
+ ---
212
+
213
+ ## Near term (post-0.4.4)
134
214
 
135
215
  ### A11y
136
216
 
@@ -147,21 +227,27 @@ PyPI: Trusted Publishing on `v*` tag push (`.github/workflows/publish.yml`).
147
227
  ### Widget expansion
148
228
 
149
229
  - [x] Per-widget `bind` for entry and listbox (`events=` on `@app.entry` and `@app.listbox`)
230
+ - [x] Per-widget `bind` for other widgets via `widget_kwargs` / widget-level options
150
231
  - [ ] Per-widget `bind` for other widgets (`text`, `combobox`, `treeview`, `button`, etc.)
151
232
  - [x] listbox callback value design: keep selection index (unify with `treeview`)
233
+ - [x] `@app.filepicker` — `filedialog` wrapper (schema/tool naming still open)
234
+ - [x] Text `wrap=` / `h_scroll=` options (v0.4.4)
152
235
 
153
236
  ### Layout DSL
154
237
 
155
238
  - [x] Deprecate index-order plural methods `col_weights`/`row_weights`/`col_minsizes`/`row_minsizes` in 0.4.1 (remove in 0.5.0).
156
239
  - [ ] 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
- - [ ] Nested frames (`Layout` inside `Layout`)
158
- - [ ] Global default for `padx`/`pady`
240
+ - [x] Nested frames (`Layout` inside `Layout`) via `Layout.frame(...)`
241
+ - [x] Global default for `padx`/`pady` via `Layout(spacing=...)`
242
+ - [x] `Layout.cluster(...)` wrapping flow; `Layout.paired(..., sync_yscroll=)`
243
+ - [x] `Layout.paired(..., line_numbers=True)` read-only line-number gutters (v0.4.4)
244
+ - [x] `Layout.target(...)` + `@app.swap(...)` dynamic region switching (v0.4.4)
159
245
 
160
246
  ### Agent / LLM integration
161
247
 
162
248
  - [ ] Expose `schema()` as Function Calling definition (bind `sequence`, treeview column defs, etc.)
163
249
  - [ ] `@agent_tool` integration (GUI operations as agent vocabulary)
164
- - [ ] `@app.filepicker` — `filedialog` wrapper (tool name in schema)
250
+ - [ ] Surface `@app.filepicker` in `schema()` / tool vocabulary
165
251
 
166
252
  ---
167
253
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nextpytk"
7
- version = "0.4.2"
7
+ version = "0.4.4"
8
8
  description = "Accessible, declarative Tkinter applications from ordinary Python functions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"