nextpytk 0.4.3__tar.gz → 0.4.5__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextpytk
3
- Version: 0.4.3
3
+ Version: 0.4.5
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 |
@@ -480,6 +481,7 @@ Options:
480
481
  | `fill` | `"x"` | Frame `fill` passed to `pack` |
481
482
  | `expand` | `False` | Frame `expand` passed to `pack` |
482
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 |
483
485
  | `side`, `padx`, `pady`, `anchor` | — | Standard `Layout` block placement options |
484
486
 
485
487
  When `sync_yscroll=True` and both widgets are `@app.text` widgets, scrolling
@@ -488,14 +490,21 @@ achieved per-widget with `@app.text(..., sync_yscroll_with="other")`; paired
488
490
  layout provides a layout-level switch that works without editing widget
489
491
  declarations.
490
492
 
491
- ### Cluster layout
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.
492
497
 
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.
498
+
499
+ ### Wrap layout
500
+
501
+ `Layout.wrap()` is a wrapping flow (Flutter `Wrap` analog): widgets are not
502
+ stretched to fill a column; each keeps the width implied by its content or
503
+ `width=` and they lay out left to right, wrapping to the next row whenever the
504
+ next widget no longer fits in the remaining frame width. Widgets in the same
505
+ row are vertically centered on their midline, so a tall `entry` aligns cleanly
506
+ with a shorter `button` or `checkbutton`. The gap inherits the layout spacing
507
+ by default (`gapx`/`gapy` override it independently).
499
508
 
500
509
  ```python
501
510
  TAGS = ["python", "tkinter", "async", "uv", "type hints"]
@@ -504,13 +513,76 @@ for tag in TAGS:
504
513
  def on_tag(values, tag=tag):
505
514
  return {"msg": f"Selected: {tag}"}
506
515
 
507
- app.run(layout=Layout(spacing=2).status("msg").cluster(*TAGS))
516
+ app.run(layout=Layout(spacing=2).status("msg").wrap(*TAGS))
508
517
  ```
509
518
 
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.
519
+ Wrap is ideal for tag clouds, toolbars, and filter UIs. The layout recomputes
520
+ rows automatically when the window is resized. Pass `gapx`/`gapy` to override
521
+ the spacing default, and `side`/`fill`/`expand` to control how the wrap frame
522
+ is packed.
523
+
524
+ Any wrap child wrapped in `Flex(name, flex=...)` absorbs leftover horizontal
525
+ space in its row (Flutter `Expanded` analog):
526
+
527
+ ```python
528
+ from nextpytk import Flex
529
+
530
+ app.run(layout=Layout().wrap("filter", Flex("search", flex=2), "ok", gapx=2))
531
+ ```
532
+
533
+ For custom positioning, `Layout.flow()` takes a `FlowDelegate` (Flutter `Flow`
534
+ analog) that computes each child's `(x, y, width, height)` from the available
535
+ `Constraints`. See `examples/wrap_demo.py` for both `Flex` and `Flow` usage.
536
+
537
+ **How it works.** `wrap` and `flow` position children with `place` (absolute
538
+ x/y) because `pack` cannot reflow onto new rows — with `pack -side left` an
539
+ overflowing child simply falls off the edge. `place` therefore implies a few
540
+ constraints:
541
+
542
+ - All children share one parent frame and are `place`-managed; they cannot
543
+ also be `pack`/`grid`-managed on the same master (mixing geometry managers
544
+ raises `TclError: conflicting geometry managers`).
545
+ - Widths come from `winfo_reqwidth()`; the frame uses `pack_propagate(False)`
546
+ and its height is explicit.
547
+ - On resize the flow is recomputed so rows re-wrap to the new width.
548
+
549
+ Because `place` bypasses Tk's native Tab traversal (which follows pack/grid
550
+ insertion order), `wrap` intercepts `<Tab>`/`<Shift-Tab>` and moves focus in
551
+ the visual row-major order instead. `Entry`/`Text` children are skipped (Tab
552
+ stays for text input); focus wraps between the first and last wrap item.
553
+
554
+ > **Note:** `Layout.cluster()` was renamed to `Layout.wrap()` in 0.5.0.
555
+ > `cluster()` remains as a deprecated alias and will be removed in 0.5.0.
556
+
557
+ ### Dynamic region switching (swap targets)
558
+
559
+ `Layout.target()` reserves a region whose contents change at runtime, mirroring
560
+ HTMX `hx-target` / `hx-swap`. Surrounding sections (toolbar, status) stay fixed
561
+ while only the target region swaps:
562
+
563
+ ```python
564
+ layout = Layout().wrap("go_dir", "go_file", "info").target("main_area")
565
+
566
+ @app.swap(
567
+ "main_area",
568
+ variants={
569
+ "dir": [Layout().section("dir_tree")],
570
+ "file": [Layout().paired("left_text", "right_text",
571
+ fill=Fill.BOTH, expand=True)],
572
+ },
573
+ default="dir",
574
+ )
575
+ def main_area():
576
+ pass
577
+
578
+ # Switch imperatively at runtime:
579
+ app.swap_view("main_area", "file")
580
+ ```
581
+
582
+ `@app.swap` variants are mounted up-front and shown/hidden via pack, so widget
583
+ state (e.g. a treeview selection or scroll position) survives switching. Each
584
+ variant is a `Layout` or a list of widget names. See
585
+ `examples/swap_demo.py`.
514
586
 
515
587
  ### Fluent DSL
516
588
 
@@ -600,7 +672,7 @@ app.run(layout=b.build())
600
672
  | `@app.combobox(name, values=..., values_key=..., readonly=..., font=...)` | ttk.Combobox | selected value `str` | `dict` |
601
673
  | `@app.menubar(name)` | tk.Menu (window menubar) | — | menu item list |
602
674
  | `@app.filepicker(name, mode=..., label=..., title=..., filetypes=..., ...)` | ttk.Button → tkinter.filedialog | selected path(s) `str`, `list[str]`, or `None` | `dict` |
603
- | `@app.text(name, width=..., height=..., font=...)` | tk.Text | full content `str` | `dict` |
675
+ | `@app.text(name, width=..., height=..., font=..., wrap=..., h_scroll=...)` | tk.Text | full content `str` | `dict` |
604
676
  | `@app.scale(name, from_=..., to=..., orient=...)` | ttk.Scale | value `str` | `dict` |
605
677
  | `@app.spinbox(name, from_=..., to=..., values=..., font=...)` | ttk.Spinbox | value `str` | `dict` |
606
678
  | `@app.listbox(name, items=..., items_key=..., selectmode=..., font=..., events=...)` | tk.Listbox | selected index `int` (`-1` if none) | `dict` |
@@ -651,6 +723,32 @@ Entry options: `placeholder`, `show`, `font`, `padding`, `width`, `state`.
651
723
  Button, checkbutton, radiobutton, spinbox, combobox, listbox, text options:
652
724
  - `font` is accepted uniformly. Under the hood, ttk widgets use a derived style so the rest of the theme (colors, maps, layout) is inherited.
653
725
 
726
+ Text options (continued):
727
+ - `wrap`: `Wrap.WORD` (default) / `Wrap.NONE` / `Wrap.CHAR`. `Wrap.NONE` keeps each logical line on a single row.
728
+ - `h_scroll`: when `True`, a horizontal scrollbar is added (and wired to `xscrollcommand`) so long lines are reachable, typically used with `wrap=Wrap.NONE`.
729
+
730
+ Every widget decorator also accepts `widget_kwargs: dict` for per-widget
731
+ design-token overrides applied after construction. Keys are widget-native
732
+ tk/ttk options (`padx`, `pady`, `bg`, `fg`, `font`, …). An invalid/unknown
733
+ key is ignored rather than aborting the build:
734
+
735
+ ```python
736
+ @app.text("log", wrap="none", h_scroll=True,
737
+ widget_kwargs={"bg": "#1e1e1e", "fg": "#dcdcdc"})
738
+ def log(value): return {}
739
+ ```
740
+
741
+ Runtime access: `app.text_widget(name)` returns the real `tk.Text`;
742
+ `app.layout_frame(name)` returns the layout section frame that owns a
743
+ widget (for manual grid/pack placement or re-parenting).
744
+ `app.on_text_set(name, hook)` registers a callback to run after a text
745
+ widget's content is replaced (used by paired line-number gutters).
746
+
747
+ Enum-like options (`wrap`, `state`, `orient`, `selectmode`, `mode`) are
748
+ validated at registration time: an invalid value raises a clear `ValueError`
749
+ naming the option and the allowed values, instead of failing later with a
750
+ `TclError` when the widget is built.
751
+
654
752
  `@app.message` creates an auto-wrapping label. `width` sets initial pixel width; `auto_width=True` (default) tracks parent container resize.
655
753
 
656
754
  ---
@@ -710,6 +808,18 @@ print(app.debug_layout())
710
808
  # → {"title": "...", "sections": [{"widgets": [{"name": "msg", "geometry": ..., ...}, ...]}]}
711
809
  ```
712
810
 
811
+ **Detecting geometry-manager conflicts.** Mixing `pack`/`grid`/`place` on one
812
+ master (e.g. manually `pack`/`grid`-ing into a `place`-managed `wrap`/`flow`
813
+ frame) cannot be fully prevented by the DSL, so `app.check_layout_conflicts()`
814
+ inspects the widget tree and reports any such mix without waiting for a
815
+ `TclError: conflicting geometry managers`:
816
+
817
+ ```python
818
+ print(app.check_layout_conflicts())
819
+ # → [{"master_class": "Frame", "managers": ["pack", "place"], ...}] on conflict
820
+ # (a warning is also emitted for each conflict)
821
+ ```
822
+
713
823
  ---
714
824
 
715
825
  ## Async-Native (asyncio + Tkinter)
@@ -741,6 +851,7 @@ uv run python examples/menubar_demo.py # menubar
741
851
  uv run python examples/filepicker_demo.py # file picker
742
852
  uv run python examples/disk_usage_flat_async.py # ncdu-style viewer (async)
743
853
  uv run python examples/paired_demo.py # side-by-side paired layout with y-scroll sync
854
+ uv run python examples/swap_demo.py # dynamic region switching (Layout.target + @app.swap)
744
855
  ```
745
856
 
746
857
  ---
@@ -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 |
@@ -456,6 +457,7 @@ Options:
456
457
  | `fill` | `"x"` | Frame `fill` passed to `pack` |
457
458
  | `expand` | `False` | Frame `expand` passed to `pack` |
458
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 |
459
461
  | `side`, `padx`, `pady`, `anchor` | — | Standard `Layout` block placement options |
460
462
 
461
463
  When `sync_yscroll=True` and both widgets are `@app.text` widgets, scrolling
@@ -464,14 +466,21 @@ achieved per-widget with `@app.text(..., sync_yscroll_with="other")`; paired
464
466
  layout provides a layout-level switch that works without editing widget
465
467
  declarations.
466
468
 
467
- ### Cluster layout
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.
468
473
 
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.
474
+
475
+ ### Wrap layout
476
+
477
+ `Layout.wrap()` is a wrapping flow (Flutter `Wrap` analog): widgets are not
478
+ stretched to fill a column; each keeps the width implied by its content or
479
+ `width=` and they lay out left to right, wrapping to the next row whenever the
480
+ next widget no longer fits in the remaining frame width. Widgets in the same
481
+ row are vertically centered on their midline, so a tall `entry` aligns cleanly
482
+ with a shorter `button` or `checkbutton`. The gap inherits the layout spacing
483
+ by default (`gapx`/`gapy` override it independently).
475
484
 
476
485
  ```python
477
486
  TAGS = ["python", "tkinter", "async", "uv", "type hints"]
@@ -480,13 +489,76 @@ for tag in TAGS:
480
489
  def on_tag(values, tag=tag):
481
490
  return {"msg": f"Selected: {tag}"}
482
491
 
483
- app.run(layout=Layout(spacing=2).status("msg").cluster(*TAGS))
492
+ app.run(layout=Layout(spacing=2).status("msg").wrap(*TAGS))
484
493
  ```
485
494
 
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.
495
+ Wrap is ideal for tag clouds, toolbars, and filter UIs. The layout recomputes
496
+ rows automatically when the window is resized. Pass `gapx`/`gapy` to override
497
+ the spacing default, and `side`/`fill`/`expand` to control how the wrap frame
498
+ is packed.
499
+
500
+ Any wrap child wrapped in `Flex(name, flex=...)` absorbs leftover horizontal
501
+ space in its row (Flutter `Expanded` analog):
502
+
503
+ ```python
504
+ from nextpytk import Flex
505
+
506
+ app.run(layout=Layout().wrap("filter", Flex("search", flex=2), "ok", gapx=2))
507
+ ```
508
+
509
+ For custom positioning, `Layout.flow()` takes a `FlowDelegate` (Flutter `Flow`
510
+ analog) that computes each child's `(x, y, width, height)` from the available
511
+ `Constraints`. See `examples/wrap_demo.py` for both `Flex` and `Flow` usage.
512
+
513
+ **How it works.** `wrap` and `flow` position children with `place` (absolute
514
+ x/y) because `pack` cannot reflow onto new rows — with `pack -side left` an
515
+ overflowing child simply falls off the edge. `place` therefore implies a few
516
+ constraints:
517
+
518
+ - All children share one parent frame and are `place`-managed; they cannot
519
+ also be `pack`/`grid`-managed on the same master (mixing geometry managers
520
+ raises `TclError: conflicting geometry managers`).
521
+ - Widths come from `winfo_reqwidth()`; the frame uses `pack_propagate(False)`
522
+ and its height is explicit.
523
+ - On resize the flow is recomputed so rows re-wrap to the new width.
524
+
525
+ Because `place` bypasses Tk's native Tab traversal (which follows pack/grid
526
+ insertion order), `wrap` intercepts `<Tab>`/`<Shift-Tab>` and moves focus in
527
+ the visual row-major order instead. `Entry`/`Text` children are skipped (Tab
528
+ stays for text input); focus wraps between the first and last wrap item.
529
+
530
+ > **Note:** `Layout.cluster()` was renamed to `Layout.wrap()` in 0.5.0.
531
+ > `cluster()` remains as a deprecated alias and will be removed in 0.5.0.
532
+
533
+ ### Dynamic region switching (swap targets)
534
+
535
+ `Layout.target()` reserves a region whose contents change at runtime, mirroring
536
+ HTMX `hx-target` / `hx-swap`. Surrounding sections (toolbar, status) stay fixed
537
+ while only the target region swaps:
538
+
539
+ ```python
540
+ layout = Layout().wrap("go_dir", "go_file", "info").target("main_area")
541
+
542
+ @app.swap(
543
+ "main_area",
544
+ variants={
545
+ "dir": [Layout().section("dir_tree")],
546
+ "file": [Layout().paired("left_text", "right_text",
547
+ fill=Fill.BOTH, expand=True)],
548
+ },
549
+ default="dir",
550
+ )
551
+ def main_area():
552
+ pass
553
+
554
+ # Switch imperatively at runtime:
555
+ app.swap_view("main_area", "file")
556
+ ```
557
+
558
+ `@app.swap` variants are mounted up-front and shown/hidden via pack, so widget
559
+ state (e.g. a treeview selection or scroll position) survives switching. Each
560
+ variant is a `Layout` or a list of widget names. See
561
+ `examples/swap_demo.py`.
490
562
 
491
563
  ### Fluent DSL
492
564
 
@@ -576,7 +648,7 @@ app.run(layout=b.build())
576
648
  | `@app.combobox(name, values=..., values_key=..., readonly=..., font=...)` | ttk.Combobox | selected value `str` | `dict` |
577
649
  | `@app.menubar(name)` | tk.Menu (window menubar) | — | menu item list |
578
650
  | `@app.filepicker(name, mode=..., label=..., title=..., filetypes=..., ...)` | ttk.Button → tkinter.filedialog | selected path(s) `str`, `list[str]`, or `None` | `dict` |
579
- | `@app.text(name, width=..., height=..., font=...)` | tk.Text | full content `str` | `dict` |
651
+ | `@app.text(name, width=..., height=..., font=..., wrap=..., h_scroll=...)` | tk.Text | full content `str` | `dict` |
580
652
  | `@app.scale(name, from_=..., to=..., orient=...)` | ttk.Scale | value `str` | `dict` |
581
653
  | `@app.spinbox(name, from_=..., to=..., values=..., font=...)` | ttk.Spinbox | value `str` | `dict` |
582
654
  | `@app.listbox(name, items=..., items_key=..., selectmode=..., font=..., events=...)` | tk.Listbox | selected index `int` (`-1` if none) | `dict` |
@@ -627,6 +699,32 @@ Entry options: `placeholder`, `show`, `font`, `padding`, `width`, `state`.
627
699
  Button, checkbutton, radiobutton, spinbox, combobox, listbox, text options:
628
700
  - `font` is accepted uniformly. Under the hood, ttk widgets use a derived style so the rest of the theme (colors, maps, layout) is inherited.
629
701
 
702
+ Text options (continued):
703
+ - `wrap`: `Wrap.WORD` (default) / `Wrap.NONE` / `Wrap.CHAR`. `Wrap.NONE` keeps each logical line on a single row.
704
+ - `h_scroll`: when `True`, a horizontal scrollbar is added (and wired to `xscrollcommand`) so long lines are reachable, typically used with `wrap=Wrap.NONE`.
705
+
706
+ Every widget decorator also accepts `widget_kwargs: dict` for per-widget
707
+ design-token overrides applied after construction. Keys are widget-native
708
+ tk/ttk options (`padx`, `pady`, `bg`, `fg`, `font`, …). An invalid/unknown
709
+ key is ignored rather than aborting the build:
710
+
711
+ ```python
712
+ @app.text("log", wrap="none", h_scroll=True,
713
+ widget_kwargs={"bg": "#1e1e1e", "fg": "#dcdcdc"})
714
+ def log(value): return {}
715
+ ```
716
+
717
+ Runtime access: `app.text_widget(name)` returns the real `tk.Text`;
718
+ `app.layout_frame(name)` returns the layout section frame that owns a
719
+ widget (for manual grid/pack placement or re-parenting).
720
+ `app.on_text_set(name, hook)` registers a callback to run after a text
721
+ widget's content is replaced (used by paired line-number gutters).
722
+
723
+ Enum-like options (`wrap`, `state`, `orient`, `selectmode`, `mode`) are
724
+ validated at registration time: an invalid value raises a clear `ValueError`
725
+ naming the option and the allowed values, instead of failing later with a
726
+ `TclError` when the widget is built.
727
+
630
728
  `@app.message` creates an auto-wrapping label. `width` sets initial pixel width; `auto_width=True` (default) tracks parent container resize.
631
729
 
632
730
  ---
@@ -686,6 +784,18 @@ print(app.debug_layout())
686
784
  # → {"title": "...", "sections": [{"widgets": [{"name": "msg", "geometry": ..., ...}, ...]}]}
687
785
  ```
688
786
 
787
+ **Detecting geometry-manager conflicts.** Mixing `pack`/`grid`/`place` on one
788
+ master (e.g. manually `pack`/`grid`-ing into a `place`-managed `wrap`/`flow`
789
+ frame) cannot be fully prevented by the DSL, so `app.check_layout_conflicts()`
790
+ inspects the widget tree and reports any such mix without waiting for a
791
+ `TclError: conflicting geometry managers`:
792
+
793
+ ```python
794
+ print(app.check_layout_conflicts())
795
+ # → [{"master_class": "Frame", "managers": ["pack", "place"], ...}] on conflict
796
+ # (a warning is also emitted for each conflict)
797
+ ```
798
+
689
799
  ---
690
800
 
691
801
  ## Async-Native (asyncio + Tkinter)
@@ -717,6 +827,7 @@ uv run python examples/menubar_demo.py # menubar
717
827
  uv run python examples/filepicker_demo.py # file picker
718
828
  uv run python examples/disk_usage_flat_async.py # ncdu-style viewer (async)
719
829
  uv run python examples/paired_demo.py # side-by-side paired layout with y-scroll sync
830
+ uv run python examples/swap_demo.py # dynamic region switching (Layout.target + @app.swap)
720
831
  ```
721
832
 
722
833
  ---
@@ -141,7 +141,119 @@ PyPI: Trusted Publishing on `v*` tag push (`.github/workflows/publish.yml`).
141
141
 
142
142
  ---
143
143
 
144
- ## Near term (post-0.4.3)
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
+ ## Snapshot (v0.4.5) — Flutter-style layout vocabulary
214
+
215
+ ### `cluster` → `wrap` rename
216
+
217
+ - `Layout.cluster()` renamed to `Layout.wrap()`, matching Flutter's `Wrap`
218
+ widget. `cluster()` is kept as a deprecated alias (emits a
219
+ `DeprecationWarning`) and will be removed in v0.5.0.
220
+ - `LayoutBuilder.cluster()` → `LayoutBuilder.wrap()` (same alias story).
221
+ - `examples/cluster_demo.py` → `examples/wrap_demo.py`; `tests/test_cluster.py`
222
+ → `tests/test_wrap.py`.
223
+ - `gap` keyword deprecated in favor of explicit `gapx`/`gapy` (SPACE tokens).
224
+
225
+ ### `Flex` — absorb leftover space (Flutter `Expanded` analog)
226
+
227
+ - Added `nextpytk.types.Flex(name, flex=...)`; a `wrap` child wrapped in `Flex`
228
+ absorbs a share of its row's leftover horizontal space proportional to its
229
+ flex factor, instead of keeping natural content width.
230
+ - `widget_names()` and `mount_frames_into` resolve `Flex`-wrapped names.
231
+
232
+ ### `FlowDelegate` / `Layout.flow` — custom flow (Flutter `Flow` analog)
233
+
234
+ - Added `nextpytk.layout.Constraints` (width/height/min/max) and
235
+ `nextpytk.layout.FlowDelegate` (abstract `compute_positions` /
236
+ `compute_height`).
237
+ - `Layout.flow(*widgets, delegate=...)` and `LayoutBuilder.flow(...)` position
238
+ children via the delegate using `place`; recomputed on `<Configure>` resize.
239
+ - `_place_flow` / `_Flow` block dispatched in `mount_frames_into` /
240
+ `pack_children_for`.
241
+ - `examples/wrap_demo.py` demonstrates `wrap`, `wrap`+`Flex`, and a
242
+ `GridDelegate` flow.
243
+
244
+ ### Theme / debug
245
+
246
+ - Button styles use `width=0` (cancel clam's `width=-11` min ~182px) so button
247
+ width tracks label text.
248
+ - `_check_radio_padding` unified to (16,12) to match buttons;
249
+ `indicatormargin=(0,0,SPACE[2],0)` adds 8px between indicator and label
250
+ (clam ignores `indicatorpadding`).
251
+ - `debug_layout()` detects geometry-manager conflicts;
252
+ `check_layout_conflicts()` warns on each.
253
+
254
+ ---
255
+
256
+ ## Near term (post-0.4.5)
145
257
 
146
258
  ### A11y
147
259
 
@@ -158,9 +270,11 @@ PyPI: Trusted Publishing on `v*` tag push (`.github/workflows/publish.yml`).
158
270
  ### Widget expansion
159
271
 
160
272
  - [x] Per-widget `bind` for entry and listbox (`events=` on `@app.entry` and `@app.listbox`)
273
+ - [x] Per-widget `bind` for other widgets via `widget_kwargs` / widget-level options
161
274
  - [ ] Per-widget `bind` for other widgets (`text`, `combobox`, `treeview`, `button`, etc.)
162
275
  - [x] listbox callback value design: keep selection index (unify with `treeview`)
163
276
  - [x] `@app.filepicker` — `filedialog` wrapper (schema/tool naming still open)
277
+ - [x] Text `wrap=` / `h_scroll=` options (v0.4.4)
164
278
 
165
279
  ### Layout DSL
166
280
 
@@ -169,6 +283,11 @@ PyPI: Trusted Publishing on `v*` tag push (`.github/workflows/publish.yml`).
169
283
  - [x] Nested frames (`Layout` inside `Layout`) via `Layout.frame(...)`
170
284
  - [x] Global default for `padx`/`pady` via `Layout(spacing=...)`
171
285
  - [x] `Layout.cluster(...)` wrapping flow; `Layout.paired(..., sync_yscroll=)`
286
+ - [x] `Layout.cluster(...)` → `Layout.wrap(...)` rename with deprecated alias (v0.4.5)
287
+ - [x] `Flex` (Flutter `Expanded` analog) for `wrap` (v0.4.5)
288
+ - [x] `FlowDelegate` / `Layout.flow` custom flow (Flutter `Flow` analog) (v0.4.5)
289
+ - [x] `Layout.paired(..., line_numbers=True)` read-only line-number gutters (v0.4.4)
290
+ - [x] `Layout.target(...)` + `@app.swap(...)` dynamic region switching (v0.4.4)
172
291
 
173
292
  ### Agent / LLM integration
174
293
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nextpytk"
7
- version = "0.4.3"
7
+ version = "0.4.5"
8
8
  description = "Accessible, declarative Tkinter applications from ordinary Python functions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -3,7 +3,13 @@
3
3
  import importlib.metadata
4
4
 
5
5
  from nextpytk.app import TkApp
6
- from nextpytk.layout import Layout, LayoutBuilder
6
+ from nextpytk.layout import (
7
+ Constraints,
8
+ FlowDelegate,
9
+ Layout,
10
+ LayoutBuilder,
11
+ )
12
+ from nextpytk.types import Flex
7
13
  from nextpytk import types
8
14
  from nextpytk.widgets import WidgetSpec
9
15
  from nextpytk.theme import (
@@ -31,6 +37,9 @@ __all__ = [
31
37
  "TkApp",
32
38
  "Layout",
33
39
  "LayoutBuilder",
40
+ "Constraints",
41
+ "FlowDelegate",
42
+ "Flex",
34
43
  "WidgetSpec",
35
44
  "types",
36
45
  "apply_theme",