nextpytk 0.4.4__tar.gz → 0.4.6__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.4
3
+ Version: 0.4.6
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
@@ -496,14 +496,15 @@ stay in lock-step with the content (no drift). Logical line numbers
496
496
  (`1..n`) follow the pane content; the right pane's numbers update as you edit.
497
497
 
498
498
 
499
- ### Cluster layout
499
+ ### Wrap layout
500
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.
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).
507
508
 
508
509
  ```python
509
510
  TAGS = ["python", "tkinter", "async", "uv", "type hints"]
@@ -512,13 +513,46 @@ for tag in TAGS:
512
513
  def on_tag(values, tag=tag):
513
514
  return {"msg": f"Selected: {tag}"}
514
515
 
515
- app.run(layout=Layout(spacing=2).status("msg").cluster(*TAGS))
516
+ app.run(layout=Layout(spacing=2).status("msg").wrap(*TAGS))
516
517
  ```
517
518
 
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.
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.
522
556
 
523
557
  ### Dynamic region switching (swap targets)
524
558
 
@@ -527,7 +561,7 @@ HTMX `hx-target` / `hx-swap`. Surrounding sections (toolbar, status) stay fixed
527
561
  while only the target region swaps:
528
562
 
529
563
  ```python
530
- layout = Layout().cluster("go_dir", "go_file", "info").target("main_area")
564
+ layout = Layout().wrap("go_dir", "go_file", "info").target("main_area")
531
565
 
532
566
  @app.swap(
533
567
  "main_area",
@@ -774,6 +808,18 @@ print(app.debug_layout())
774
808
  # → {"title": "...", "sections": [{"widgets": [{"name": "msg", "geometry": ..., ...}, ...]}]}
775
809
  ```
776
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
+
777
823
  ---
778
824
 
779
825
  ## Async-Native (asyncio + Tkinter)
@@ -806,6 +852,7 @@ uv run python examples/filepicker_demo.py # file picker
806
852
  uv run python examples/disk_usage_flat_async.py # ncdu-style viewer (async)
807
853
  uv run python examples/paired_demo.py # side-by-side paired layout with y-scroll sync
808
854
  uv run python examples/swap_demo.py # dynamic region switching (Layout.target + @app.swap)
855
+ uv run python examples/bottom_bar_demo.py # pinned bottom bar (section side="bottom")
809
856
  ```
810
857
 
811
858
  ---
@@ -472,14 +472,15 @@ stay in lock-step with the content (no drift). Logical line numbers
472
472
  (`1..n`) follow the pane content; the right pane's numbers update as you edit.
473
473
 
474
474
 
475
- ### Cluster layout
475
+ ### Wrap layout
476
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.
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).
483
484
 
484
485
  ```python
485
486
  TAGS = ["python", "tkinter", "async", "uv", "type hints"]
@@ -488,13 +489,46 @@ for tag in TAGS:
488
489
  def on_tag(values, tag=tag):
489
490
  return {"msg": f"Selected: {tag}"}
490
491
 
491
- app.run(layout=Layout(spacing=2).status("msg").cluster(*TAGS))
492
+ app.run(layout=Layout(spacing=2).status("msg").wrap(*TAGS))
492
493
  ```
493
494
 
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.
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.
498
532
 
499
533
  ### Dynamic region switching (swap targets)
500
534
 
@@ -503,7 +537,7 @@ HTMX `hx-target` / `hx-swap`. Surrounding sections (toolbar, status) stay fixed
503
537
  while only the target region swaps:
504
538
 
505
539
  ```python
506
- layout = Layout().cluster("go_dir", "go_file", "info").target("main_area")
540
+ layout = Layout().wrap("go_dir", "go_file", "info").target("main_area")
507
541
 
508
542
  @app.swap(
509
543
  "main_area",
@@ -750,6 +784,18 @@ print(app.debug_layout())
750
784
  # → {"title": "...", "sections": [{"widgets": [{"name": "msg", "geometry": ..., ...}, ...]}]}
751
785
  ```
752
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
+
753
799
  ---
754
800
 
755
801
  ## Async-Native (asyncio + Tkinter)
@@ -782,6 +828,7 @@ uv run python examples/filepicker_demo.py # file picker
782
828
  uv run python examples/disk_usage_flat_async.py # ncdu-style viewer (async)
783
829
  uv run python examples/paired_demo.py # side-by-side paired layout with y-scroll sync
784
830
  uv run python examples/swap_demo.py # dynamic region switching (Layout.target + @app.swap)
831
+ uv run python examples/bottom_bar_demo.py # pinned bottom bar (section side="bottom")
785
832
  ```
786
833
 
787
834
  ---
@@ -210,7 +210,96 @@ PyPI: Trusted Publishing on `v*` tag push (`.github/workflows/publish.yml`).
210
210
 
211
211
  ---
212
212
 
213
- ## Near term (post-0.4.4)
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
+ ## Snapshot (v0.4.6) — section placement fix + layout ergonomics
257
+
258
+ ### Bug fix: `section(..., side=...)` now controls the section frame placement
259
+
260
+ - `_pack_section_frame` hardcoded `side="top"`, ignoring the requested
261
+ placement side. A `side="bottom"` section declared after an `expand=True`
262
+ block (e.g. a status bar under a growing body) was pushed off-view and
263
+ collapsed to 1x1.
264
+ - Fixed by honoring `block.side` on the section frame pack call.
265
+
266
+ ### Child packing decoupled from frame placement
267
+
268
+ - Added `_Row.child_side` to separate the section frame's placement side
269
+ (`side`) from the side used to pack children *inside* the frame. A
270
+ multi-widget section still lays its children out left-to-right (`"left"`)
271
+ regardless of where the frame itself is placed (`side="top"`/`"bottom"`/
272
+ `"left"`/`"right"`); a single-widget section packs its child with the
273
+ frame's own placement side.
274
+
275
+ ### Tests / example
276
+
277
+ - Added `tests/test_layout_side.py` (regression coverage: frame side
278
+ honored, default `top` preserved, multi-widget child `"left"` + frame
279
+ `"top"`/`"bottom"`).
280
+ - Added `examples/bottom_bar_demo.py` demonstrating a `side="bottom"` bar
281
+ pinned below an expandable body.
282
+
283
+ ### Known limitation: `side="bottom"` + focusable children
284
+
285
+ `side="bottom"` controls only the pack **parcel** (visual placement). Tk's
286
+ `tk_focusNext` / `tk_focusPrev` traverse the widget tree in *insertion order*
287
+ (`winfo children`), which is independent of the pack parcel. Because a
288
+ `side="bottom"` section must be declared early in the tree (so an
289
+ `expand=True` sibling doesn't push it off-view), any focusable child in it
290
+ (Entry, Button, …) lands **early** in the Tab order even though it is
291
+ visually at the bottom.
292
+
293
+ This is fine for non-focusable chrome (labels, a `status_bar`), but **not**
294
+ for bottom-docked interactive widgets: visual order (top → bottom controls)
295
+ and focus order (bottom controls first) diverge. The fix is to wire the
296
+ visual tab order explicitly (the same `_wire_tab_order` used by `wrap` /
297
+ `flow`, which intercepts `<Key-Tab>` / `<Shift-Key-Tab>` and moves focus in
298
+ visual row-major order). Planned to generalize for `side="bottom"` sections.
299
+
300
+ ---
301
+
302
+ ## Near term (post-0.4.5)
214
303
 
215
304
  ### A11y
216
305
 
@@ -240,6 +329,9 @@ PyPI: Trusted Publishing on `v*` tag push (`.github/workflows/publish.yml`).
240
329
  - [x] Nested frames (`Layout` inside `Layout`) via `Layout.frame(...)`
241
330
  - [x] Global default for `padx`/`pady` via `Layout(spacing=...)`
242
331
  - [x] `Layout.cluster(...)` wrapping flow; `Layout.paired(..., sync_yscroll=)`
332
+ - [x] `Layout.cluster(...)` → `Layout.wrap(...)` rename with deprecated alias (v0.4.5)
333
+ - [x] `Flex` (Flutter `Expanded` analog) for `wrap` (v0.4.5)
334
+ - [x] `FlowDelegate` / `Layout.flow` custom flow (Flutter `Flow` analog) (v0.4.5)
243
335
  - [x] `Layout.paired(..., line_numbers=True)` read-only line-number gutters (v0.4.4)
244
336
  - [x] `Layout.target(...)` + `@app.swap(...)` dynamic region switching (v0.4.4)
245
337
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nextpytk"
7
- version = "0.4.4"
7
+ version = "0.4.6"
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",
@@ -904,6 +904,7 @@ class TkApp:
904
904
  sec_id = id(master)
905
905
  if sec_id not in seen_sections:
906
906
  seen_sections[sec_id] = {
907
+ "master": master,
907
908
  "master_class": master.winfo_class(),
908
909
  "master_geometry": master.winfo_geometry(),
909
910
  "widgets": [],
@@ -939,8 +940,76 @@ class TkApp:
939
940
  pass
940
941
  seen_sections[sec_id]["widgets"].append(info)
941
942
  out["sections"] = list(seen_sections.values())
943
+
944
+ # Detect geometry-manager conflicts. Tk forbids mixing pack/grid/place
945
+ # on the same master; a registered widget's master may also host
946
+ # manual widgets (e.g. an overlay placed with ``place``), which only
947
+ # surface by inspecting the live child tree via ``winfo_manager``.
948
+ conflicts: list[dict[str, Any]] = []
949
+ for sec in out["sections"]:
950
+ master = sec.get("master")
951
+ managers: set[str] = set()
952
+ widget_names: list[str] = []
953
+ for w in sec["widgets"]:
954
+ if w["manager"]:
955
+ managers.add(w["manager"])
956
+ widget_names.append(w["name"])
957
+ if master is not None:
958
+ try:
959
+ for child in master.winfo_children():
960
+ mgr = child.winfo_manager()
961
+ if mgr:
962
+ managers.add(mgr)
963
+ label = child.cget("text") if child.winfo_class() in (
964
+ "Label", "TLabel"
965
+ ) else child.winfo_class()
966
+ widget_names.append(label or child.winfo_name())
967
+ except tk.TclError:
968
+ pass
969
+ if len(managers) > 1:
970
+ conflicts.append({
971
+ "master_class": sec["master_class"],
972
+ "managers": sorted(managers),
973
+ "widgets": widget_names,
974
+ })
975
+ sec.pop("master", None)
976
+ out["conflicts"] = conflicts
942
977
  return out
943
978
 
979
+ def check_layout_conflicts(self) -> list[dict[str, Any]]:
980
+ """Return a list of geometry-manager conflicts, warning on each.
981
+
982
+ Tcl/Tk raises ``TclError: conflicting geometry managers`` at runtime
983
+ when a widget is managed by a different manager than its siblings on
984
+ the same master. This helper surfaces the same condition without
985
+ waiting for a failure: it inspects the live widget tree and reports
986
+ any master whose children are split across multiple managers
987
+ (``pack``/``grid``/``place``).
988
+
989
+ Each returned item describes one conflicting master::
990
+
991
+ {
992
+ "master_class": "Frame",
993
+ "managers": ["grid", "pack"],
994
+ "widgets": ["left_pane", "location"],
995
+ }
996
+
997
+ A ``warnings.warn`` is emitted for every conflict found, so it can be
998
+ used as a debugging aid after ``build_widgets`` (or inside ``run``).
999
+ """
1000
+ debug = self.debug_layout()
1001
+ conflicts: list[dict[str, Any]] = []
1002
+ for conflict in debug.get("conflicts", []):
1003
+ conflicts.append(conflict)
1004
+ warnings.warn(
1005
+ "Conflicting geometry managers on same master: "
1006
+ f"{conflict['managers']} for widgets "
1007
+ f"{conflict['widgets']} ({conflict['master_class']}). "
1008
+ "Pack/grid/place cannot be mixed on one widget.",
1009
+ stacklevel=2,
1010
+ )
1011
+ return conflicts
1012
+
944
1013
  def _spec(self, name: str) -> WidgetSpec | None:
945
1014
  for w in self._widgets:
946
1015
  if w.name == name: