nextpytk 0.4.6__tar.gz → 0.4.8__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.6
3
+ Version: 0.4.8
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
@@ -495,6 +495,11 @@ panes and both gutters share a **single vertical scrollbar**, so the gutters
495
495
  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
+ > **Note:** `line_numbers=True` takes precedence over `sync_yscroll`. The
499
+ > shared-scrollbar layout it installs keeps both panes (and both gutters)
500
+ > scrolled in lock-step, so passing `sync_yscroll=False` together with
501
+ > `line_numbers=True` has no effect. If the panes must scroll independently,
502
+ > leave `line_numbers` off.
498
503
 
499
504
  ### Wrap layout
500
505
 
@@ -853,6 +858,7 @@ uv run python examples/disk_usage_flat_async.py # ncdu-style viewer (async
853
858
  uv run python examples/paired_demo.py # side-by-side paired layout with y-scroll sync
854
859
  uv run python examples/swap_demo.py # dynamic region switching (Layout.target + @app.swap)
855
860
  uv run python examples/bottom_bar_demo.py # pinned bottom bar (section side="bottom")
861
+ uv run python examples/live_validation.py # live validation via Tcl-var trace ingest (ingest_trace=True)
856
862
  ```
857
863
 
858
864
  ---
@@ -875,6 +881,10 @@ uv run python examples/bottom_bar_demo.py # pinned bottom bar (section sid
875
881
  - **`CustomTkinter`**: Modern look via Canvas rendering. nextpytk takes the opposite approach: use native widgets and embed A11y from the start.
876
882
  - **`TkRouter`** (israel-dryer, author of ttkbootstrap): Declarative view routing with URL-style paths, animated transitions, and history stack. Complements nextpytk's `multiview` — routing vs widget composition.
877
883
 
884
+ ## Changelog
885
+
886
+ See [CHANGES.md](./CHANGES.md) for the full release history.
887
+
878
888
  ## License
879
889
 
880
890
  MIT
@@ -471,6 +471,11 @@ panes and both gutters share a **single vertical scrollbar**, so the gutters
471
471
  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
+ > **Note:** `line_numbers=True` takes precedence over `sync_yscroll`. The
475
+ > shared-scrollbar layout it installs keeps both panes (and both gutters)
476
+ > scrolled in lock-step, so passing `sync_yscroll=False` together with
477
+ > `line_numbers=True` has no effect. If the panes must scroll independently,
478
+ > leave `line_numbers` off.
474
479
 
475
480
  ### Wrap layout
476
481
 
@@ -829,6 +834,7 @@ uv run python examples/disk_usage_flat_async.py # ncdu-style viewer (async
829
834
  uv run python examples/paired_demo.py # side-by-side paired layout with y-scroll sync
830
835
  uv run python examples/swap_demo.py # dynamic region switching (Layout.target + @app.swap)
831
836
  uv run python examples/bottom_bar_demo.py # pinned bottom bar (section side="bottom")
837
+ uv run python examples/live_validation.py # live validation via Tcl-var trace ingest (ingest_trace=True)
832
838
  ```
833
839
 
834
840
  ---
@@ -851,6 +857,10 @@ uv run python examples/bottom_bar_demo.py # pinned bottom bar (section sid
851
857
  - **`CustomTkinter`**: Modern look via Canvas rendering. nextpytk takes the opposite approach: use native widgets and embed A11y from the start.
852
858
  - **`TkRouter`** (israel-dryer, author of ttkbootstrap): Declarative view routing with URL-style paths, animated transitions, and history stack. Complements nextpytk's `multiview` — routing vs widget composition.
853
859
 
860
+ ## Changelog
861
+
862
+ See [CHANGES.md](./CHANGES.md) for the full release history.
863
+
854
864
  ## License
855
865
 
856
866
  MIT
@@ -0,0 +1,61 @@
1
+ # ROADMAP — nextpytk
2
+
3
+ > **Note:** Past release history lives in [CHANGES.md](./CHANGES.md).
4
+ > This file is the forward-looking plan (near-term, then v0.5.0, then longer term).
5
+
6
+ ## Near term (post-0.4.8)
7
+
8
+ ### A11y
9
+
10
+ - [ ] `@app.status` live-region behavior (ARIA `aria-live` equivalent / WCAG 4.1.3)
11
+ - [ ] Tk 9.1 `tk accessible set_acc_*` real-device verification (9.1b0 + NVDA)
12
+ - [ ] Role vocabulary mapping to Tk side
13
+ - [ ] Automatic `emit_selection_change` (leverage `apply_state` knowing which widgets changed)
14
+
15
+ ### Type hints
16
+
17
+ - [ ] Further TypedDict / Protocol coverage for decorator arguments
18
+ - [ ] Fluent API equivalent to `_GridBuilder.columnconfigure`
19
+
20
+ ### Widget expansion
21
+
22
+ - [ ] Per-widget `bind` for other widgets (`text`, `combobox`, `treeview`, `button`, etc.)
23
+ - [ ] `@app.filepicker` — `filedialog` wrapper (schema/tool naming still open)
24
+
25
+ ### Layout DSL
26
+
27
+ - [ ] 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.
28
+ - [ ] Wire visual Tab order for `side="bottom"` sections via `_wire_tab_order` (see v0.4.6 note in CHANGES.md).
29
+
30
+ ### Agent / LLM integration
31
+
32
+ - [ ] Expose `schema()` as Function Calling definition (bind `sequence`, treeview column defs, etc.)
33
+ - [ ] `@agent_tool` integration (GUI operations as agent vocabulary)
34
+ - [ ] Surface `@app.filepicker` in `schema()` / tool vocabulary
35
+
36
+ ---
37
+
38
+ ## v0.5.0 — themes / chrome decoupling
39
+
40
+ - Remove deprecated `theme=True/False` support; `theme` becomes `str` only.
41
+ - Remove deprecated `EventSeq.DOUBLE_CLICK`; use `EventSeq.PRIMARY_DOUBLE_CLICK` or `DOUBLE_BUTTON_1`.
42
+ - Remove deprecated index-order plural methods `col_weights`/`row_weights`/`col_minsizes`/`row_minsizes` and `Layout.cluster()` alias.
43
+ - Decouple `Layout().header()` / `.status()` chrome from Kizashi-specific tokens so they render safely with any ttk theme (`"none"`, `"clam"`, etc.).
44
+ - Extract Kizashi as a standalone ttk theme package so it can be proposed upstream or reused independently of nextpytk.
45
+
46
+ ---
47
+
48
+ ## Longer term
49
+
50
+ ### ttk Style layer
51
+
52
+ - [ ] `Layout.style("my_button", background=..., font=...)` style definitions
53
+
54
+ ### Declarative components
55
+
56
+ - [ ] `@app.component` decorator (React-like reuse, replacing `Layout`)
57
+ - [ ] State type definitions and validation (no Pydantic)
58
+
59
+ ### Testing
60
+
61
+ - [ ] WidgetSpec unit tests
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nextpytk"
7
- version = "0.4.6"
7
+ version = "0.4.8"
8
8
  description = "Accessible, declarative Tkinter applications from ordinary Python functions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -268,11 +268,17 @@ class TkApp:
268
268
  *,
269
269
  debug: bool = False,
270
270
  theme: bool | str = "kizashi",
271
+ ingest_trace: bool = False,
271
272
  ):
272
273
  self._title = title
273
274
  self._debug = debug
274
275
  self._theme = self._normalize_theme(theme)
275
276
  self._kizashi = self._theme == "kizashi"
277
+ # When True, a ``trace_add("write", ...)`` is installed on every
278
+ # registered Tcl variable. User edits (e.g. typing into an entry)
279
+ # are ingested back into ``state`` (kept as the single source of
280
+ # truth) and batched into a single sync pass at the next idle event.
281
+ self._ingest_trace = ingest_trace
276
282
  self._acc_supported: bool | None = None
277
283
  self._a11y_last_toggle: dict[str, str] = {}
278
284
  self._widgets: list[WidgetSpec] = []
@@ -280,6 +286,15 @@ class TkApp:
280
286
  self._root: tk.Tk | None = None
281
287
  self._tk_widgets: dict[str, tk.Widget] = {}
282
288
  self._tk_vars: dict[str, tk.Variable] = {}
289
+ # Re-entrancy guard: while the framework itself calls ``var.set``
290
+ # (from apply_state), a write trace must NOT re-ingest that value.
291
+ self._syncing_var_keys: set[str] = set()
292
+ # Keys whose Tcl var changed from a user edit, awaiting a batched flush.
293
+ self._pending_ingest: set[str] = set()
294
+ self._ingest_flush_job: str | None = None
295
+ # True while widgets are being built; build-time ``var.set`` calls
296
+ # (e.g. placeholders) must not be ingested into state.
297
+ self._building = False
283
298
  self._widget_masters: dict[str, tk.Misc] = {}
284
299
  self._row_pack_jobs: list[tuple[tk.Frame, Any]] = []
285
300
  self._grid_pack_jobs: list[tuple[tk.Frame, Any]] = []
@@ -356,7 +371,11 @@ class TkApp:
356
371
  self._text_inner.clear()
357
372
  self._text_scrollbars.clear()
358
373
  self._text_scroll_sync.clear()
374
+ self._text_set_hooks.clear()
359
375
  self._a11y_last_toggle.clear()
376
+ self._pending_ingest.clear()
377
+ self._ingest_flush_job = None
378
+ self._syncing_var_keys.clear()
360
379
  self._current_view = None
361
380
  self._current_stage = None
362
381
  self._swap_targets.clear()
@@ -1289,7 +1308,7 @@ class TkApp:
1289
1308
  layout, row_jobs, grid_jobs = jobs_by_view[view]
1290
1309
  layout.pack_children_for(self, row_jobs, grid_jobs)
1291
1310
  else:
1292
- self.pack_view_widgets(view, center_kinds=centered, fill="x", pady=2)
1311
+ self.pack_view_widgets(view, center_kinds=centered, fill="x", pady=t.SPACE[1])
1293
1312
 
1294
1313
  def _on_tab_changed(_event: tk.Event[tk.Misc] | None = None) -> None:
1295
1314
  current = nb.select()
@@ -1461,7 +1480,7 @@ class TkApp:
1461
1480
  layout, row_jobs, grid_jobs = jobs_by_stage[stage]
1462
1481
  layout.pack_children_for(self, row_jobs, grid_jobs)
1463
1482
  else:
1464
- self.pack_view_widgets(stage, center_kinds=centered, fill="x", pady=2)
1483
+ self.pack_view_widgets(stage, center_kinds=centered, fill="x", pady=t.SPACE[1])
1465
1484
 
1466
1485
  active = str(self._state.get(key, stages[0] if stages else ""))
1467
1486
  if active not in stages:
@@ -2608,7 +2627,7 @@ class TkApp:
2608
2627
  """
2609
2628
  width = options.get("width", 300)
2610
2629
  height = options.get("height", 200)
2611
- bg = options.get("bg", "#f0f0f0")
2630
+ bg = options.get("bg", t.SURFACE)
2612
2631
  description = options.get("description")
2613
2632
  items = options.get("items")
2614
2633
  takefocus = options.get("takefocus")
@@ -2657,15 +2676,21 @@ class TkApp:
2657
2676
  update_to_apply.pop(stage_key, None)
2658
2677
 
2659
2678
  self._state.update(update_to_apply)
2660
- for key, val in update_to_apply.items():
2661
- var = self._tk_vars.get(key)
2662
- if var is None:
2663
- continue
2664
- s = "" if val is None else str(val)
2665
- var.set(s)
2666
- w = self._tk_widgets.get(key)
2667
- if isinstance(w, (tk.Entry, ttk.Entry)):
2668
- self._apply_entry_value_after_state(key, w, var, s)
2679
+ # Mark the keys being written by the framework so their write traces
2680
+ # (if ingest_trace is on) do not loop the value back into state.
2681
+ self._syncing_var_keys.update(update_to_apply)
2682
+ try:
2683
+ for key, val in update_to_apply.items():
2684
+ var = self._tk_vars.get(key)
2685
+ if var is None:
2686
+ continue
2687
+ s = "" if val is None else str(val)
2688
+ var.set(s)
2689
+ w = self._tk_widgets.get(key)
2690
+ if isinstance(w, (tk.Entry, ttk.Entry)):
2691
+ self._apply_entry_value_after_state(key, w, var, s)
2692
+ finally:
2693
+ self._syncing_var_keys.difference_update(update_to_apply)
2669
2694
  if full:
2670
2695
  self._sync_widgets()
2671
2696
  else:
@@ -2690,6 +2715,61 @@ class TkApp:
2690
2715
  # Keep state in sync with the rendered stage.
2691
2716
  self._state[key] = new_stage
2692
2717
 
2718
+ def _register_var(self, key: str, var: tk.Variable) -> None:
2719
+ """Register a Tcl variable under *key*, installing an ingest trace.
2720
+
2721
+ When ``ingest_trace=True``, a ``write`` trace feeds ``var.get()`` back
2722
+ into ``state[key]`` so user edits (typing into an entry, toggling a
2723
+ checkbutton, dragging a scale) become first-class state transitions.
2724
+ Framework-originated writes (from ``apply_state``) are excluded via
2725
+ ``_syncing_var_keys`` so they do not loop back. Multiple edits are
2726
+ coalesced into one ``after_idle`` sync pass.
2727
+ """
2728
+ self._tk_vars[key] = var
2729
+ if self._ingest_trace:
2730
+ try:
2731
+ var.trace_add(
2732
+ "write",
2733
+ lambda _n, _i, _op, k=key: self._on_var_ingest(k),
2734
+ )
2735
+ except tk.TclError:
2736
+ # Variable may already be deleted; ingest is best-effort.
2737
+ pass
2738
+
2739
+ def _on_var_ingest(self, key: str) -> None:
2740
+ """Trace callback: a user edit changed ``var``; queue it for ingest."""
2741
+ # Skip writes that the framework itself issued from apply_state, and
2742
+ # any writes that happen while widgets are being built (placeholders).
2743
+ if key in self._syncing_var_keys or self._building:
2744
+ return
2745
+ self._pending_ingest.add(key)
2746
+ if self._root is None:
2747
+ return
2748
+ if self._ingest_flush_job is None:
2749
+ try:
2750
+ self._ingest_flush_job = self._root.after_idle(self._flush_ingest)
2751
+ except tk.TclError:
2752
+ self._ingest_flush_job = None
2753
+
2754
+ def _flush_ingest(self) -> None:
2755
+ """Flush queued user edits into ``state`` in one batched pass."""
2756
+ self._ingest_flush_job = None
2757
+ if not self._pending_ingest:
2758
+ return
2759
+ pending = self._pending_ingest
2760
+ self._pending_ingest = set()
2761
+ update: dict[str, Any] = {}
2762
+ for key in pending:
2763
+ var = self._tk_vars.get(key)
2764
+ if var is None:
2765
+ continue
2766
+ try:
2767
+ update[key] = var.get()
2768
+ except tk.TclError:
2769
+ continue
2770
+ if update:
2771
+ self._apply_state_dict(update, full=False)
2772
+
2693
2773
  def _known_state_keys(self) -> set[str]:
2694
2774
  """Return the set of state keys that have meaning in the app."""
2695
2775
  keys: set[str] = set()
@@ -3345,6 +3425,19 @@ class TkApp:
3345
3425
 
3346
3426
  self._first_focusable: tk.Widget | None = None
3347
3427
 
3428
+ self._building = True
3429
+ try:
3430
+ self._build_widgets_inner()
3431
+ finally:
3432
+ self._building = False
3433
+
3434
+ def _build_widgets_inner(self) -> None:
3435
+ """Create tkinter widgets from registered specs (inside build guard)."""
3436
+ if self._root is None:
3437
+ return
3438
+
3439
+ self._first_focusable: tk.Widget | None = None
3440
+
3348
3441
  for spec in self._widgets:
3349
3442
  if spec.kind == "paned":
3350
3443
  master = self._widget_masters.get(spec.name, self._root)
@@ -3745,7 +3838,7 @@ class TkApp:
3745
3838
  )
3746
3839
  w = ttk.Entry(master, textvariable=var, style=style)
3747
3840
  self._tk_widgets[spec.name] = w
3748
- self._tk_vars[spec.name] = var
3841
+ self._register_var(spec.name, var)
3749
3842
  for opt in ("show", "width", "state"):
3750
3843
  if opt in e:
3751
3844
  w.configure(**{opt: e[opt]})
@@ -3791,7 +3884,7 @@ class TkApp:
3791
3884
  # anchor is a ttk layout option (style.layout), not a constructor kwarg
3792
3885
  # for ttk.Checkbutton; left alignment is enforced by the Kizashi style.
3793
3886
  self._tk_widgets[spec.name] = w
3794
- self._tk_vars[key] = var
3887
+ self._register_var(key, var)
3795
3888
  if spec.on_update is not None:
3796
3889
  fn = spec.on_update
3797
3890
  w.configure(command=lambda s=spec, f=fn, v=var, k=key:
@@ -3802,7 +3895,7 @@ class TkApp:
3802
3895
  gk = e.get("group_key", "radio")
3803
3896
  val = e.get("rb_value", "")
3804
3897
  if gk not in self._tk_vars:
3805
- self._tk_vars[gk] = tk.StringVar(value="")
3898
+ self._register_var(gk, tk.StringVar(value=""))
3806
3899
  var = self._tk_vars[gk]
3807
3900
  # padding is owned by the theme style (44px min target size)
3808
3901
  style = "TRadiobutton"
@@ -3932,7 +4025,7 @@ class TkApp:
3932
4025
  length=e.get("length", 200 if orient_str == "horizontal" else 100),
3933
4026
  ) # type: ignore[arg-type]
3934
4027
  self._tk_widgets[spec.name] = w
3935
- self._tk_vars[key] = var
4028
+ self._register_var(key, var)
3936
4029
  self._state[key] = str(var.get())
3937
4030
  if spec.on_update is not None:
3938
4031
  fn = spec.on_update
@@ -3963,7 +4056,7 @@ class TkApp:
3963
4056
  kwargs["font"] = e["font"]
3964
4057
  w = ttk.Spinbox(master, textvariable=var, **kwargs)
3965
4058
  self._tk_widgets[spec.name] = w
3966
- self._tk_vars[key] = var
4059
+ self._register_var(key, var)
3967
4060
  if init_val:
3968
4061
  self._state[key] = init_val
3969
4062
  if spec.on_update is not None:
@@ -3989,7 +4082,7 @@ class TkApp:
3989
4082
  kwargs["font"] = e["font"]
3990
4083
  w = ttk.Combobox(master, **kwargs)
3991
4084
  self._tk_widgets[spec.name] = w
3992
- self._tk_vars[key] = var
4085
+ self._register_var(key, var)
3993
4086
  if init_val:
3994
4087
  self._state[key] = init_val
3995
4088
  if spec.on_update is not None:
@@ -4000,7 +4093,7 @@ class TkApp:
4000
4093
  def _build_listbox(self, spec: WidgetSpec, master: tk.Misc) -> None:
4001
4094
  e = spec.extras
4002
4095
  kwargs_lb: dict[str, Any] = {
4003
- "bg": t.SURFACE,
4096
+ "bg": t.BG,
4004
4097
  "fg": t.TEXT,
4005
4098
  "selectbackground": t.ACCENT_RAMP[200],
4006
4099
  "selectforeground": t.ACCENT_RAMP[700],
@@ -783,6 +783,25 @@ def _place_paired_with_gutters(
783
783
  _bind_gutter_edit_sync(app, text_b, gutter_b)
784
784
 
785
785
 
786
+ def _logical_line_count(text: tk.Text) -> int:
787
+ """Return the number of logical (non-wrapped) lines in *text*.
788
+
789
+ ``index("end-1c")`` counts *display* lines: a long line that wraps to
790
+ several rows inflates the count when ``wrap`` is ``word``/``char``. We
791
+ count newline characters instead, which is wrap-independent. The last
792
+ logical line may or may not carry a trailing newline (``text_set`` omits
793
+ it; a hand-inserted buffer may keep it), so the count is adjusted by
794
+ whether the content ends with a newline.
795
+ """
796
+ try:
797
+ content = text.get("1.0", "end-1c")
798
+ except tk.TclError:
799
+ return 0
800
+ if not content:
801
+ return 0
802
+ return content.count("\n") + (0 if content.endswith("\n") else 1)
803
+
804
+
786
805
  def _reconcile_gutter(gutter: tk.Text, text: tk.Text) -> None:
787
806
  """Fill a gutter with logical line numbers, matching the text line count.
788
807
 
@@ -790,17 +809,26 @@ def _reconcile_gutter(gutter: tk.Text, text: tk.Text) -> None:
790
809
  gutter is rewritten so its scroll range matches the pane. Idempotent:
791
810
  rewriting only happens when the count differs.
792
811
  """
793
- n = int(text.index("end-1c").split(".")[0])
812
+ # Re-entry guard: rewriting the gutter fires yscrollcommand on the shared
813
+ # scrollbar (via update_idletasks below), which re-enters this function
814
+ # through _chain_yview. Without a guard that recursion never terminates.
815
+ if getattr(gutter, "_syncing_gutter", False):
816
+ return
817
+ n = _logical_line_count(text)
794
818
  gutter_lines = int(gutter.index("end-1c").split(".")[0])
795
819
  if gutter_lines == n:
796
820
  return
797
821
  lines = "\n".join(str(i) for i in range(1, n + 1))
798
- gutter.configure(state="normal")
799
- gutter.delete("1.0", "end")
800
- gutter.insert("1.0", lines)
801
- gutter.configure(state="disabled")
802
- # Ensure the new content is reflected in the shared scrollbar range.
803
- gutter.update_idletasks()
822
+ gutter._syncing_gutter = True # type: ignore[attr-defined]
823
+ try:
824
+ gutter.configure(state="normal")
825
+ gutter.delete("1.0", "end")
826
+ gutter.insert("1.0", lines)
827
+ gutter.configure(state="disabled")
828
+ # Ensure the new content is reflected in the shared scrollbar range.
829
+ gutter.update_idletasks()
830
+ finally:
831
+ gutter._syncing_gutter = False # type: ignore[attr-defined]
804
832
 
805
833
 
806
834
  def _chain_yview(
@@ -857,14 +885,20 @@ def _make_gutter(parent: tk.Misc, name: str) -> tk.Text:
857
885
 
858
886
  def _populate_gutters(gutter: tk.Text, text: tk.Text) -> None:
859
887
  """Fill a gutter with logical line numbers (1..n)."""
860
- n = int(text.index("end-1c").split(".")[0])
888
+ if getattr(gutter, "_syncing_gutter", False):
889
+ return
890
+ n = _logical_line_count(text)
861
891
  lines = "\n".join(str(i) for i in range(1, n + 1))
862
- gutter.configure(state="normal")
863
- gutter.delete("1.0", "end")
864
- gutter.insert("1.0", lines)
865
- gutter.configure(state="disabled")
866
- # Ensure the new content is reflected in the shared scrollbar range.
867
- gutter.update_idletasks()
892
+ gutter._syncing_gutter = True # type: ignore[attr-defined]
893
+ try:
894
+ gutter.configure(state="normal")
895
+ gutter.delete("1.0", "end")
896
+ gutter.insert("1.0", lines)
897
+ gutter.configure(state="disabled")
898
+ # Ensure the new content is reflected in the shared scrollbar range.
899
+ gutter.update_idletasks()
900
+ finally:
901
+ gutter._syncing_gutter = False # type: ignore[attr-defined]
868
902
 
869
903
 
870
904
  def _bind_gutter_edit_sync(app: TkApp, text: tk.Text, gutter: tk.Text) -> None:
@@ -1347,6 +1381,14 @@ class Layout:
1347
1381
  the gutters always stay in lock-step with the content (no drift). Line
1348
1382
  numbers are logical rows of the text widget (``1..n``).
1349
1383
 
1384
+ .. note::
1385
+ ``line_numbers=True`` takes precedence over ``sync_yscroll``: the
1386
+ shared-scrollbar layout it installs always keeps the two panes (and
1387
+ both gutters) scrolled in lock-step, so passing
1388
+ ``sync_yscroll=False`` with ``line_numbers=True`` has no effect. If
1389
+ you need the panes to scroll independently, do **not** enable
1390
+ ``line_numbers``.
1391
+
1350
1392
  Example::
1351
1393
 
1352
1394
  app.text("left", readonly=True, sync_yscroll_with="right")
@@ -60,7 +60,7 @@ def apply_theme(root):
60
60
  # --- entries ------------------------------------------------------------
61
61
  style.configure(
62
62
  "TEntry",
63
- fieldbackground=t.SURFACE,
63
+ fieldbackground=t.BG,
64
64
  foreground=t.TEXT,
65
65
  bordercolor=t.DIVIDER,
66
66
  lightcolor=t.DIVIDER,
@@ -225,6 +225,11 @@ def apply_theme(root):
225
225
  font=t.font("body"),
226
226
  focuscolor=t.FOCUS,
227
227
  padding=_check_radio_padding,
228
+ # clam's radiobutton gap between indicator and label is driven by
229
+ # ``indicatormargin`` (indicatorpadding is ignored by clam). Widen the
230
+ # right margin to SPACE[2] (8px) to match TCheckbutton, so the symbol
231
+ # and label read as separate without crowding.
232
+ indicatormargin=(0, 0, t.SPACE[2], 0),
228
233
  )
229
234
  style.layout(
230
235
  "TRadiobutton",
@@ -252,20 +257,30 @@ def apply_theme(root):
252
257
  )
253
258
 
254
259
  # --- scale --------------------------------------------------------------
260
+ # "Card" thumb: page-ground (BG) fill with an ACCENT border. The border
261
+ # provides the non-text contrast needed to pick the thumb out of the
262
+ # SURFACE trough (~5.3:1, WCAG 1.4.11); the BG fill against the ACCENT
263
+ # border stays legible too (~5.9:1). Hover/pressed shift the border.
255
264
  style.configure(
256
265
  "TScale",
257
266
  background=t.BG,
258
267
  troughcolor=t.SURFACE,
259
- bordercolor=t.DIVIDER,
260
- lightcolor=t.DIVIDER,
261
- darkcolor=t.DIVIDER,
268
+ bordercolor=t.ACCENT,
269
+ lightcolor=t.ACCENT,
270
+ darkcolor=t.ACCENT,
262
271
  padding=(t.SPACE[1], t.SPACE[2]),
263
272
  )
273
+ style.map(
274
+ "TScale",
275
+ bordercolor=[("active", t.ACCENT_HOVER), ("pressed", t.ACCENT_PRESSED)],
276
+ lightcolor=[("active", t.ACCENT_HOVER), ("pressed", t.ACCENT_PRESSED)],
277
+ darkcolor=[("active", t.ACCENT_HOVER), ("pressed", t.ACCENT_PRESSED)],
278
+ )
264
279
 
265
280
  # --- spinbox ------------------------------------------------------------
266
281
  style.configure(
267
282
  "TSpinbox",
268
- fieldbackground=t.SURFACE,
283
+ fieldbackground=t.BG,
269
284
  foreground=t.TEXT,
270
285
  bordercolor=t.DIVIDER,
271
286
  lightcolor=t.DIVIDER,
@@ -287,7 +302,7 @@ def apply_theme(root):
287
302
  # --- combobox -----------------------------------------------------------
288
303
  style.configure(
289
304
  "TCombobox",
290
- fieldbackground=t.SURFACE,
305
+ fieldbackground=t.BG,
291
306
  foreground=t.TEXT,
292
307
  bordercolor=t.DIVIDER,
293
308
  lightcolor=t.DIVIDER,
@@ -304,7 +319,7 @@ def apply_theme(root):
304
319
  lightcolor=[("focus", t.FOCUS)],
305
320
  darkcolor=[("focus", t.FOCUS)],
306
321
  arrowcolor=[("active", t.ACCENT_HOVER), ("pressed", t.ACCENT_PRESSED)],
307
- fieldbackground=[("readonly", t.SURFACE), ("active", t.SURFACE)],
322
+ fieldbackground=[("readonly", t.BG), ("active", t.BG)],
308
323
  selectbackground=[("focus", t.ACCENT_RAMP[200])],
309
324
  selectforeground=[("focus", t.TEXT)],
310
325
  )
@@ -316,7 +331,7 @@ def apply_theme(root):
316
331
  # foreground so the current value stays readable.
317
332
  style.configure(
318
333
  "Readonly.TCombobox",
319
- fieldbackground=t.NEUTRAL[100],
334
+ fieldbackground=t.BG,
320
335
  foreground=t.TEXT,
321
336
  bordercolor=t.DIVIDER,
322
337
  lightcolor=t.DIVIDER,
@@ -333,7 +348,7 @@ def apply_theme(root):
333
348
  lightcolor=[("focus", t.FOCUS)],
334
349
  darkcolor=[("focus", t.FOCUS)],
335
350
  arrowcolor=[("active", t.ACCENT_HOVER), ("pressed", t.ACCENT_PRESSED)],
336
- fieldbackground=[("readonly", t.NEUTRAL[100]), ("active", t.NEUTRAL[100])],
351
+ fieldbackground=[("readonly", t.BG), ("active", t.BG)],
337
352
  selectbackground=[("focus", t.ACCENT_RAMP[300])],
338
353
  selectforeground=[("focus", t.TEXT)],
339
354
  foreground=[("readonly", t.TEXT), ("active", t.TEXT)],
@@ -355,18 +370,22 @@ def apply_theme(root):
355
370
  )
356
371
 
357
372
  # --- scrollbar ----------------------------------------------------------
373
+ # Thumb matches the scale thumb: BG fill with an ACCENT border on a
374
+ # SURFACE trough. The border gives the non-text contrast to pick the
375
+ # thumb out of the trough; hover/pressed shift the border accent ramp.
358
376
  style.configure(
359
377
  "TScrollbar",
360
- background=t.NEUTRAL[300],
378
+ background=t.BG,
361
379
  troughcolor=t.SURFACE,
362
- bordercolor=t.DIVIDER,
380
+ bordercolor=t.ACCENT,
363
381
  arrowcolor=t.TEXT,
364
382
  gripcount=0,
365
383
  borderwidth=0,
366
384
  )
367
385
  style.map(
368
386
  "TScrollbar",
369
- background=[("active", t.NEUTRAL[400]), ("pressed", t.NEUTRAL[500])],
387
+ bordercolor=[("active", t.ACCENT_HOVER), ("pressed", t.ACCENT_PRESSED)],
388
+ background=[("active", t.NEUTRAL[100]), ("pressed", t.NEUTRAL[200])],
370
389
  arrowcolor=[("active", t.TEXT), ("pressed", t.TEXT)],
371
390
  )
372
391
 
@@ -538,23 +557,35 @@ def field_row(parent, row, label_text, textvariable, *, label_width=None, entry_
538
557
 
539
558
  def window_header(parent, title, subtitle=None):
540
559
  """Standard top-of-window block: left-aligned title, optional muted
541
- subtitle, closed by a strong 2px rule. Replaces stacks of centered
542
- gray label boxes — hierarchy comes from type scale, not from boxes.
560
+ subtitle. Hierarchy comes from type scale and spacing, not from boxes
561
+ or divider lines (Kizashi design principle).
543
562
 
544
563
  Returns (title_label, subtitle_label_or_None).
545
564
  """
546
- title_lbl = tk.Label(parent, text=title, bg=t.BG, fg=t.TEXT,
565
+ header_bg = t.BG # "#faf8f4" — same as page background
566
+
567
+ # Title: Kizashi h4 with space-2 (16px) internal padding for consistent
568
+ # background coverage. No bottom margin — spacing comes from subtitle.
569
+ title_lbl = tk.Label(parent, text=title, bg=header_bg, fg=t.TEXT,
547
570
  font=t.font("h4"), anchor="w",
548
- bd=0, highlightthickness=0)
571
+ bd=0, highlightthickness=0,
572
+ padx=t.SPACE[2], pady=t.SPACE[2])
549
573
  title_lbl.pack(fill="x")
574
+
550
575
  sub_lbl = None
551
576
  if subtitle is not None:
552
- sub_lbl = tk.Label(parent, text=subtitle, bg=t.BG, fg=t.TEXT_MUTED,
577
+ # Kizashi small text with space-2 padding.
578
+ # Top margin space-2 (16px) creates the gap from title.
579
+ sub_lbl = tk.Label(parent, text=subtitle, bg=header_bg, fg=t.TEXT_MUTED,
553
580
  font=t.font("small"), anchor="w",
554
- bd=0, highlightthickness=0)
555
- sub_lbl.pack(fill="x", pady=(t.SPACE[1], 0))
556
- rule = tk.Frame(parent, height=2, bg=t.DIVIDER, bd=0, highlightthickness=0)
557
- rule.pack(fill="x", pady=(t.SPACE[3], t.SPACE[4]))
581
+ bd=0, highlightthickness=0,
582
+ padx=t.SPACE[2], pady=t.SPACE[2])
583
+ sub_lbl.pack(fill="x")
584
+
585
+ # Add space-4 (32px) below the header to separate from the first section
586
+ # (Kizashi section spacing principle).
587
+ tk.Frame(parent, height=t.SPACE[4], bg=header_bg).pack(fill="x")
588
+
558
589
  return title_lbl, sub_lbl
559
590
 
560
591
 
@@ -581,7 +612,7 @@ def data_list(parent, columns, height=12, selectmode: str = "browse"):
581
612
  else:
582
613
  tree.column(key, width=width, minwidth=width, anchor=anchor, stretch=False)
583
614
 
584
- tree.tag_configure("odd", background=t.NEUTRAL[100])
615
+ tree.tag_configure("odd", background=t.BG)
585
616
  tree.tag_configure("even", background=t.BG)
586
617
 
587
618
  sb = ttk.Scrollbar(container, orient="vertical", command=tree.yview)
@@ -605,17 +636,16 @@ def clear_rows(tree):
605
636
 
606
637
 
607
638
  def status_bar(root, textvariable=None, text=""):
608
- """Bottom status bar: a 2px rule, then a left-aligned muted line.
639
+ """Bottom status bar: a left-aligned muted line.
609
640
 
610
641
  Use for hints and state instead of centered label boxes.
642
+ Hierarchy comes from spacing and typography (Kizashi principle).
611
643
  Returns the label.
612
644
  """
613
645
  bar = tk.Frame(root, bg=t.BG, bd=0, highlightthickness=0)
614
646
  bar.pack(side="bottom", fill="x")
615
- rule = tk.Frame(bar, height=2, bg=t.DIVIDER, bd=0, highlightthickness=0)
616
- rule.pack(fill="x")
617
647
  lbl = tk.Label(bar, bg=t.BG, fg=t.TEXT_MUTED, font=t.font("small"),
618
- anchor="w", padx=t.SPACE[6], pady=t.SPACE[2],
648
+ anchor="w", padx=t.SPACE[2], pady=t.SPACE[2],
619
649
  bd=0, highlightthickness=0)
620
650
  if textvariable is not None:
621
651
  lbl.configure(textvariable=textvariable)
@@ -22,7 +22,7 @@ import tkinter.font as tkfont
22
22
  # Contrast pairs are regression-tested in tests/test_tokens.py
23
23
  # (WCAG 1.4.3 Contrast (Minimum), 1.4.11 Non-text Contrast).
24
24
  BG = "#faf8f4" # Background (page ground)
25
- SURFACE = "#f1eae0" # Surface (fields, panels)
25
+ SURFACE = "#f1ece3" # Surface (fields, panels) — Kizashi spec
26
26
  CARD = "#ffffff" # Card background (design uses pure white on BG)
27
27
  TEXT = "#302b24" # Text Primary
28
28
  TEXT_SECONDARY = "#4a4339" # Paragraph text on cards/sections
nextpytk-0.4.6/ROADMAP.md DELETED
@@ -1,377 +0,0 @@
1
- # ROADMAP — nextpytk
2
-
3
- ## Snapshot (v0.4.0)
4
-
5
- ### Implemented widgets (`@app.*`)
6
-
7
- | kind | Notes |
8
- |------|-------|
9
- | `label` / `status` / `message` | Display / word-wrap. `status` sets `role=status` metadata (not a live region yet) |
10
- | `button` / `entry` | ttk, `state=` support |
11
- | `checkbutton` / `radiobutton` / `scale` / `spinbox` | ttk |
12
- | `combobox` | ttk.Combobox (`readonly`, `values`) |
13
- | `menubar` | Window menubar with cascades / `enabled_if` |
14
- | `text` / `listbox` / `canvas` | tk (Text: tags, readonly, paired y-scroll sync) |
15
- | `treeview` | Multi-column (`show="headings"`) |
16
- | `paned` | `ttk.Panedwindow` + `with app.pane(...)` |
17
- | `progressbar` | `state[key]` / `{name}_running` for determinate/indeterminate |
18
- | `bind` | Global shortcuts (annotates matching button) |
19
-
20
- Infrastructure: `Layout` (pack/grid, `.header()` / `.status()` chrome), `@app.multiview`, `@app.stages` / `run(stages=..., tabposition=...)`, `run` / `run_async` / `spawn` / `@app.job`, `schema()`, `debug_layout()`, builder registry, a11y choke point `_apply_a11y`.
21
-
22
- Theme: `tokens` + `apply_theme` / layout helpers (contrast + 44px target-size regressions).
23
-
24
- PyPI: Trusted Publishing on `v*` tag push (`.github/workflows/publish.yml`).
25
-
26
- ### Examples
27
-
28
- - `disk_usage_flat_async.py` — async ncdu-style viewer (`run_async` + `spawn`)
29
- - `combobox_demo.py`, `menubar_demo.py`, `header_demo.py`, `text_sync_demo.py`
30
-
31
- ---
32
-
33
- ## Snapshot (v0.4.1) — post-0.4 fixes
34
-
35
- ### Text widget ergonomics
36
-
37
- - `app.text_widget(name)` returns the real `tk.Text` instance (the outer container is still available via `app.widget(name)`).
38
- - `app.text_get(name)` returns the current full contents.
39
- - `text` widgets now sync from state via `apply_state({name: ...})`, including read-only panes.
40
- - `_on_text_change()` now reads from the real `tk.Text` instead of the empty container frame.
41
- - A11y traits now attach to the real `tk.Text` rather than the container frame.
42
-
43
- ### Layout documentation
44
-
45
- - Horizontal splits are already supported via `app.paned(..., orient=Orient.HORIZONTAL)` and `Layout.paned(...)`; see `examples/paned_split.py` and `examples/text_sync_demo.py`.
46
- - `on_ready` is intended for post-build setup, not reparenting already-packed widgets. Use up-front `paned` / `grid` / `section` declarations for dynamic-looking layouts.
47
- - The reported layout issue was **not** a nextpytk bug: widgets were placed in row 0 but `row_weights(0, 1)` gave all vertical stretch to the empty row 1. The framework behaved as documented; the fix was on the application side to use `row_weight(0, 1)` or a `paned` layout. This motivated stronger README warnings and the deprecation of the index-order plural methods below.
48
-
49
- ### Deprecations in v0.4.1 (removal in v0.5.0)
50
-
51
- - `col_weights(*weights)`, `row_weights(*weights)`, `col_minsizes(*sizes)`, and `row_minsizes(*sizes)` on `_GridBuilder` are now deprecated. They assigned weights by index position, which was easy to misuse.
52
- - `LayoutBuilder.grid(col_weights=..., row_weights=...)` keyword arguments are also deprecated.
53
- - Migrate to the explicit singular API: `col_weight(col, weight)`, `row_weight(row, weight)`, `col_minsize(col, size)`, `row_minsize(row, size)`.
54
-
55
- ### Design system documentation (v0.4.1)
56
-
57
- - Added a "Design System" section to README covering `Fill`/`Sticky`/`Orient` typed constants, `SPACE` token usage, and theme helpers.
58
- - Documented the `SPACE` scale (`1:4, 2:8, 3:12, 4:16, 6:24, 8:32`) and the rule of using tokens instead of bare integers for padding/spacing.
59
- - Updated `examples/grid_temp.py` and `examples/widget_gallery.py` to use the singular weight/min size API and `SPACE` tokens.
60
-
61
- ### Widget-level font and padding (v0.4.1)
62
-
63
- - Added `font=` to `@app.button`, `@app.entry`, `@app.checkbutton`, `@app.radiobutton`, `@app.text`, `@app.spinbox`, `@app.listbox`, and `@app.combobox` (in addition to the existing `@app.label`).
64
- - Added `padding=` to `@app.entry` so users can declaratively increase visual height (`ttk.Entry` does not expose `height` directly).
65
- - For ttk widgets whose widget-level `configure()` rejects `-font` / `-padding` (Button, Entry, Checkbutton, Radiobutton), nextpytk now creates a derived `ttk.Style` per widget. The new style inherits the base style's layout and theme, applies only the requested overrides, and leaves other theme properties intact.
66
- - Updated README.md, README.ja.md, and `tests/test_api.py` to cover the new options.
67
-
68
- ### Theme API redesign (v0.4.1)
69
-
70
- - `TkApp(theme=...)` now accepts `bool | str`.
71
- - `"kizashi"` (default) applies the built-in Kizashi design system.
72
- - `"none"` leaves the platform default ttk theme untouched.
73
- - Any other string switches to that installed ttk theme without Kizashi overrides.
74
- - `theme=True` and `theme=False` are deprecated in v0.4.1 and will be removed in v0.5.0. Migrate to `"kizashi"` and `"none"`.
75
- - Updated `README.md`, `README.ja.md`, and `tests/test_api.py`.
76
-
77
- ### Deferred
78
-
79
- - `listbox` callback stays index-based by design (unifies with `treeview`, handles duplicate items, and avoids forcing the framework to reverse-map strings). Passing the display string would be a breaking change and is intentionally not pursued.
80
-
81
- ---
82
-
83
- ## Snapshot (v0.4.2) — event-sequence generalization + listbox items_key
84
-
85
- ### Problem
86
-
87
- `@app.bind(...)` accepts arbitrary Tk event strings, but the only typed constants lived under `nextpytk.types.ListboxEvent` (e.g. `ListboxEvent.RETURN`). Using a listbox-oriented name for a global shortcut or an entry-level Enter binding was confusing.
88
-
89
- ### Change
90
-
91
- - Added a generic `nextpytk.types.EventSeq` class with common event sequences: `RETURN`, `ESCAPE`, `BACKSPACE`, `DELETE`, `TAB`, `DOUBLE_BUTTON_1`, `DOUBLE_BUTTON_2`, `DOUBLE_BUTTON_3`, `LISTBOX_SELECT`.
92
- - Kept `ListboxEvent` as a backward-compatible subclass of `EventSeq` so existing code continues to work. Existing constants (`SELECT`, `RETURN`, `KEY_BACKSPACE`, `KEY_DELETE`) still resolve to the same string values. `DOUBLE_CLICK` is kept as a deprecated alias of `DOUBLE_BUTTON_1` and will be removed in v0.5.0.
93
- - Updated `EventSeqLike` / `ListboxEventLike` to share the same literal union.
94
- - Updated `app.py` docstrings for `@app.bind` and `@app.listbox` to reference `EventSeq` instead of `ListboxEvent`.
95
-
96
- ### listbox / combobox dynamic choices
97
-
98
- - Added `items_key=` option to `@app.listbox` for state-driven list contents, symmetric to `treeview`'s existing `rows_key=`.
99
- - Added `values_key=` option to `@app.combobox` for state-driven dropdown values, following the same widget-native naming principle.
100
- - With these keys, `apply_state({items_key: [...]})` / `apply_state({values_key: [...]})` refresh the widget choices while keeping the selection in `state[name]` / `state[key]`.
101
- - Static `items=` / `values=` remain available and still work as before. When both are provided, the dynamic key takes precedence at runtime.
102
- - Full (`apply_state`) and partial (`_apply_state_dict`) updates both refresh the affected widgets when the dynamic key is touched.
103
- - Selection is kept when it remains valid; if it becomes invalid (removed from values, out of range for listbox) it is reset to the widget's empty state.
104
-
105
- ### entry widget-level events
106
-
107
- - Added `events=` option to `@app.entry` for widget-level event bindings.
108
- - Accepts `dict[str, ListboxEventHandler]` mapping event sequences (e.g. `EventSeq.RETURN`) to handlers.
109
- - Handlers receive a dict of current effective entry values (same shape as button callbacks) and may return a state update dict.
110
- - Enables patterns like "press Enter in the search entry to trigger search" without a global `<Return>` bind or an application-level button.
111
-
112
- ### A11y-aware primary mouse-button helpers
113
-
114
- - Added `EventSeq.PRIMARY_CLICK`, `EventSeq.PRIMARY_DOUBLE_CLICK`, and `EventSeq.PRIMARY_BUTTON_RELEASE` lazy descriptors to `nextpytk.types`. These return the tkinter event sequence for the OS-configured primary mouse button, accounting for left/right button swap on Windows, macOS, and Linux/GNOME. (The underlying `primary_click()`, `primary_double_click()`, and `primary_button_release()` functions are also available for direct use.)
115
- - `_primary_button_number()` detects the swap setting per platform and caches the result with `functools.lru_cache(maxsize=1)` so repeated event lookups do not hit the OS each time.
116
- - `EventSeq.PRIMARY_CLICK`, `EventSeq.PRIMARY_DOUBLE_CLICK`, and `EventSeq.PRIMARY_BUTTON_RELEASE` are lazy descriptors that re-evaluate on each access (the underlying `_primary_button_number()` is cached, so the cost is negligible). These are the recommended way to reference primary-button events in widget-level bindings (e.g. `events={EventSeq.PRIMARY_DOUBLE_CLICK: ...}` on `@app.listbox`).
117
- - Updated `examples/disk_usage_flat_async.py` and the `@app.listbox` docstring to use `EventSeq.PRIMARY_DOUBLE_CLICK` instead of the deprecated `EventSeq.DOUBLE_CLICK`.
118
-
119
- ### Tests
120
-
121
- - Added `tests/test_types.py` covering `DOUBLE_BUTTON_*`, the `DOUBLE_CLICK` alias, default primary-button behavior, and mocked Windows/macOS/GNOME left-handed settings returning the swapped button number.
122
-
123
- ### Design principle
124
-
125
- - Use the tkinter-native vocabulary per widget: listbox has "items", treeview has "rows", combobox has "values". The corresponding dynamic keys are `items_key`, `rows_key`, and `values_key`. This is more intuitive than forcing a single generic name across widgets.
126
-
127
- ### Deprecation note
128
-
129
- - `ListboxEvent` is soft-deprecated in v0.4.2. New code should use `EventSeq` directly. No removal timeline yet.
130
-
131
- ---
132
-
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
- ## 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)
303
-
304
- ### A11y
305
-
306
- - [ ] `@app.status` live-region behavior (ARIA `aria-live` equivalent / WCAG 4.1.3)
307
- - [ ] Tk 9.1 `tk accessible set_acc_*` real-device verification (9.1b0 + NVDA)
308
- - [ ] Role vocabulary mapping to Tk side
309
- - [ ] Automatic `emit_selection_change` (leverage `apply_state` knowing which widgets changed)
310
-
311
- ### Type hints
312
-
313
- - [ ] Further TypedDict / Protocol coverage for decorator arguments
314
- - [ ] Fluent API equivalent to `_GridBuilder.columnconfigure`
315
-
316
- ### Widget expansion
317
-
318
- - [x] Per-widget `bind` for entry and listbox (`events=` on `@app.entry` and `@app.listbox`)
319
- - [x] Per-widget `bind` for other widgets via `widget_kwargs` / widget-level options
320
- - [ ] Per-widget `bind` for other widgets (`text`, `combobox`, `treeview`, `button`, etc.)
321
- - [x] listbox callback value design: keep selection index (unify with `treeview`)
322
- - [x] `@app.filepicker` — `filedialog` wrapper (schema/tool naming still open)
323
- - [x] Text `wrap=` / `h_scroll=` options (v0.4.4)
324
-
325
- ### Layout DSL
326
-
327
- - [x] Deprecate index-order plural methods `col_weights`/`row_weights`/`col_minsizes`/`row_minsizes` in 0.4.1 (remove in 0.5.0).
328
- - [ ] 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.
329
- - [x] Nested frames (`Layout` inside `Layout`) via `Layout.frame(...)`
330
- - [x] Global default for `padx`/`pady` via `Layout(spacing=...)`
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)
335
- - [x] `Layout.paired(..., line_numbers=True)` read-only line-number gutters (v0.4.4)
336
- - [x] `Layout.target(...)` + `@app.swap(...)` dynamic region switching (v0.4.4)
337
-
338
- ### Agent / LLM integration
339
-
340
- - [ ] Expose `schema()` as Function Calling definition (bind `sequence`, treeview column defs, etc.)
341
- - [ ] `@agent_tool` integration (GUI operations as agent vocabulary)
342
- - [ ] Surface `@app.filepicker` in `schema()` / tool vocabulary
343
-
344
- ---
345
-
346
- ### v0.5.0 themes / chrome decoupling
347
-
348
- - Remove deprecated `theme=True/False` support; `theme` becomes `str` only.
349
- - Remove deprecated `EventSeq.DOUBLE_CLICK`; use `EventSeq.PRIMARY_DOUBLE_CLICK` or `DOUBLE_BUTTON_1`.
350
- - Decouple `Layout().header()` / `.status()` chrome from Kizashi-specific tokens so they render safely with any ttk theme (`"none"`, `"clam"`, etc.).
351
- - Extract Kizashi as a standalone ttk theme package so it can be proposed upstream or reused independently of nextpytk.
352
-
353
- ### Longer term
354
-
355
- ### ttk Style layer
356
-
357
- - [x] Theme switching (`ttk.Style().theme_use(...)`)
358
- - [ ] `Layout.style("my_button", background=..., font=...)` style definitions
359
-
360
- ### Declarative components
361
-
362
- - [ ] `@app.component` decorator (React-like reuse, replacing `Layout`)
363
- - [ ] State type definitions and validation (no Pydantic)
364
-
365
- ### Testing
366
-
367
- - [x] Headless execution tests (`tests/` — withdrawn root, build/apply_state/callback drive)
368
- - [ ] WidgetSpec unit tests
369
-
370
- ---
371
-
372
- ## Earlier snapshots
373
-
374
- ### v0.3.0
375
-
376
- Builder registry, headless tests, a11y wiring (`_apply_a11y`), error policy, bugfixes.
377
- `run_async` / `spawn` / `@app.job`, public runtime API (`build_widgets`, `apply_state`, `register_widget_builder`, …).
File without changes
File without changes
File without changes