nextpytk 0.4.1__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  .venv/
2
2
  __pycache__/
3
3
  dist/
4
- *.egg-info/
4
+ *.egg-info/
5
+ .DS_Store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextpytk
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Accessible, declarative Tkinter applications from ordinary Python functions.
5
5
  Project-URL: Homepage, https://github.com/nishimotz/nextpytk
6
6
  Project-URL: Repository, https://github.com/nishimotz/nextpytk
@@ -28,15 +28,21 @@ Accessible, declarative Tkinter applications from ordinary Python functions.
28
28
 
29
29
  Register widgets as plain Python functions, declare layout separately, and
30
30
  keep roles/descriptions in one place. The decorator style is Flask-inspired;
31
- `schema()` exports the same structure for agents and tools.
31
+ `schema()` exports the registered widget structure as JSON-compatible data.
32
32
  Uses ttk widgets where available.
33
33
 
34
34
  ---
35
35
 
36
36
  ## Quick Start
37
37
 
38
+ Start with an app where a button updates a message.
39
+
40
+ ```bash
41
+ pip install nextpytk
42
+ ```
43
+
38
44
  ```python
39
- from nextpytk import TkApp, Layout
45
+ from nextpytk import TkApp
40
46
 
41
47
  app = TkApp(title="Hello")
42
48
 
@@ -45,28 +51,144 @@ def msg():
45
51
  return "Hello, world!"
46
52
 
47
53
  @app.button("greet", label="Greet")
48
- def on_greet(values):
54
+ def on_greet():
49
55
  return {"msg": "Button clicked!"}
50
56
 
51
- app.run(layout=Layout().section("msg").section("greet"))
57
+ app.run(layout=["msg", "greet"])
58
+ ```
59
+
60
+ ---
61
+
62
+ ## How it works
63
+
64
+ In nextpytk you register widgets by name and declare layout separately.
65
+
66
+ This example registers two widgets: `msg` and `greet`.
67
+
68
+ ```python
69
+ @app.status("msg")
52
70
  ```
53
71
 
54
- **Three layout styles — pick the one that fits:**
72
+ `msg` is a status area that shows a message.
73
+
74
+ ```python
75
+ @app.button("greet", label="Greet")
76
+ ```
77
+
78
+ `greet` is a button labeled "Greet".
79
+
80
+ Finally, name the display order:
55
81
 
56
82
  ```python
57
- # 1) Simple list (easiest)
58
83
  app.run(layout=["msg", "greet"])
84
+ ```
59
85
 
60
- # 2) Fluent DSL
61
- app.run(layout=Layout().section("msg").section("greet"))
86
+ When you press the button, the callback returns this `dict`:
62
87
 
63
- # 3) with-block (context manager)
64
- with app.layout() as b:
65
- b.section("msg")
66
- b.section("greet")
67
- app.run(layout=b.build())
88
+ ```python
89
+ {"msg": "Button clicked!"}
68
90
  ```
69
91
 
92
+ nextpytk merges that into the app `state`.
93
+
94
+ Because `msg` is the name of the registered status area, its text becomes
95
+ "Button clicked!".
96
+
97
+ So the basic flow is:
98
+
99
+ * Register widgets by name
100
+ * List those names in the layout
101
+ * Return a `dict` from a callback
102
+ * `state` updates and matching widgets refresh
103
+
104
+ ---
105
+
106
+ ## Using input values
107
+
108
+ Next, change the app so you type a name and press Greet.
109
+
110
+ ```python
111
+ from nextpytk import TkApp
112
+
113
+ app = TkApp(title="Hello")
114
+
115
+ @app.entry("name", placeholder="Name")
116
+ def on_name():
117
+ return {}
118
+
119
+ @app.status("msg")
120
+ def msg():
121
+ return "Enter your name"
122
+
123
+ @app.button("greet", label="Greet")
124
+ def on_greet(values):
125
+ name = values["name"]
126
+ return {"msg": f"Hello, {name}!"}
127
+
128
+ app.run(layout=["name", "greet", "msg"])
129
+ ```
130
+
131
+ `entry` registers a text field.
132
+
133
+ ```python
134
+ @app.entry("name", placeholder="Name")
135
+ ```
136
+
137
+ Again, `"name"` is the widget name. An on-change callback is required; if you
138
+ only read the field from a button, a no-arg callback that returns `{}` is enough.
139
+
140
+ In the button callback, read the current field value from `values`:
141
+
142
+ ```python
143
+ def on_greet(values):
144
+ name = values["name"]
145
+ ```
146
+
147
+ `values["name"]` is the current value of the `entry` registered as `name`.
148
+
149
+ For example, if you type
150
+
151
+ ```text
152
+ Taro
153
+ ```
154
+
155
+ and press the button, the callback returns:
156
+
157
+ ```python
158
+ {"msg": "Hello, Taro!"}
159
+ ```
160
+
161
+ That dict merges into `state` and updates the status area named `msg`.
162
+
163
+ ## `values` and `state`
164
+
165
+ Two dictionaries appear here:
166
+
167
+ * `values`
168
+ Input values at the moment the callback runs
169
+
170
+ * `state`
171
+ Shared current state for the whole app
172
+
173
+ A button callback reads `values`, then returns a `dict` of state changes:
174
+
175
+ ```python
176
+ def on_greet(values):
177
+ name = values["name"]
178
+ return {"msg": f"Hello, {name}!"}
179
+ ```
180
+
181
+ The flow is:
182
+
183
+ * Type into an `entry`
184
+ * Press the button
185
+ * Read input from `values`
186
+ * Return updates as a `dict`
187
+ * Those updates merge into `state`
188
+ * Matching widgets refresh from `state`
189
+
190
+ That is nextpytk's basic state-update model.
191
+
70
192
  ---
71
193
 
72
194
  ## Design System
@@ -91,9 +213,80 @@ Layout().section("msg", side=Side.LEFT, fill=Fill.X)
91
213
  | `Relief` | `Relief.FLAT/RAISED/SUNKEN/...` | border style |
92
214
  | `Justify` | `Justify.LEFT/RIGHT/CENTER` | text alignment |
93
215
  | `SelectMode` | `SelectMode.SINGLE/BROWSE/MULTIPLE/EXTENDED` | listbox mode |
216
+ | `EventSeq` | `EventSeq.RETURN/ESCAPE/BACKSPACE/DELETE/TAB/...` | event sequences for bindings |
217
+ | `EventSeq` (mouse) | `EventSeq.BUTTON_1/2/3`, `DOUBLE_BUTTON_1/2/3`, `PRIMARY_DOUBLE_CLICK` | mouse event sequences |
218
+ | `EventSeq` (virtual) | `EventSeq.LISTBOX_SELECT/COMBOBOX_SELECTED/NOTEBOOK_TAB_CHANGED/...` | virtual events |
94
219
 
95
220
  Each type has a matching `*Like` literal alias (e.g. `FillLike`), so raw strings still work when needed.
96
221
 
222
+ ### Event sequences
223
+
224
+ Use `EventSeq` constants for widget-level event bindings (`events=` on
225
+ `@app.listbox` and `@app.entry`). These handlers are **separate from** the
226
+ widget's select/change callback:
227
+
228
+ | API | Handler argument | Typical use |
229
+ |-----|------------------|-------------|
230
+ | `@app.listbox` select callback | selected index `int` (`-1` if none) | react to selection |
231
+ | `@app.listbox(..., events=...)` | current **state** dict | Return / double-click / Backspace |
232
+ | `@app.entry` change callback | value `str` | react to typing |
233
+ | `@app.entry(..., events=...)` | **entry values** dict (all entries) | Return to submit, like a button |
234
+
235
+ ```python
236
+ from nextpytk.types import EventSeq, SelectMode
237
+
238
+ @app.listbox(
239
+ "results",
240
+ items_key="results_items",
241
+ selectmode=SelectMode.BROWSE,
242
+ events={
243
+ EventSeq.RETURN: lambda state: open_child(),
244
+ EventSeq.PRIMARY_DOUBLE_CLICK: lambda state: open_child(),
245
+ EventSeq.BACKSPACE: lambda state: go_parent(),
246
+ },
247
+ )
248
+ def on_results_select(idx: int) -> dict:
249
+ return {"status": f"selected:{idx}"}
250
+
251
+ @app.entry(
252
+ "query",
253
+ events={
254
+ EventSeq.RETURN: lambda values: {
255
+ "status": f"search:{(values.get('query') or '').strip()}"
256
+ },
257
+ },
258
+ )
259
+ def on_query(value: str) -> dict:
260
+ return {}
261
+ ```
262
+
263
+ `EventSeq.PRIMARY_CLICK`, `EventSeq.PRIMARY_DOUBLE_CLICK`, and
264
+ `EventSeq.PRIMARY_BUTTON_RELEASE` are a11y-aware lazy descriptors that
265
+ return the event sequence for the OS-configured primary mouse button
266
+ (accounting for left/right button swap on Windows, macOS, and Linux/GNOME).
267
+
268
+ ### Dynamic choices
269
+
270
+ State-driven choices mirror treeview's `rows_key`: keep the selection in
271
+ `state[name]` / `state[key]`, and refresh the options via a separate key.
272
+
273
+ ```python
274
+ @app.listbox("results", items_key="results_items")
275
+ def on_results_select(idx: int) -> dict:
276
+ return {}
277
+
278
+ @app.combobox("folder", values_key="folder_values")
279
+ def on_folder(value: str) -> dict:
280
+ return {}
281
+
282
+ # Later (button, job, or initial_state):
283
+ app.apply_state({
284
+ "results_items": ["a", "b", "c"],
285
+ "folder_values": ["INBOX", "Sent"],
286
+ })
287
+ ```
288
+
289
+ Omit `items_key` / `values_key` to keep a static `items=` / `values=` list.
97
290
  ### Spacing tokens
98
291
 
99
292
  ```python
@@ -177,6 +370,24 @@ view_layouts = {
177
370
 
178
371
  ## Layout DSL
179
372
 
373
+ Three styles — pick the one that fits:
374
+
375
+ ```python
376
+ from nextpytk import Layout
377
+
378
+ # 1) Simple list (easiest)
379
+ app.run(layout=["msg", "greet"])
380
+
381
+ # 2) Fluent DSL
382
+ app.run(layout=Layout().section("msg").section("greet"))
383
+
384
+ # 3) with-block (context manager)
385
+ with app.layout() as b:
386
+ b.section("msg")
387
+ b.section("greet")
388
+ app.run(layout=b.build())
389
+ ```
390
+
180
391
  ### Simple list
181
392
 
182
393
  ```python
@@ -189,6 +400,118 @@ Each name gets its own pack-based section. Extra kwargs forwarded to `section()`
189
400
  Layout.from_list(["a", "b"], fill="both", expand=True)
190
401
  ```
191
402
 
403
+ ### Layout spacing
404
+
405
+ The default block padding comes from the design token `SPACE[1]` (4 px). Pass
406
+ `spacing=...` to a `Layout` constructor to change the default padding for every
407
+ block it creates:
408
+
409
+ ```python
410
+ from nextpytk import tokens
411
+
412
+ layout = Layout(spacing=2).section("a").section("b").grid().widget("c").end_grid()
413
+ ```
414
+
415
+ `spacing` accepts a key from `nextpytk.tokens.SPACE` (1, 2, 3, 4, 6, 8). It sets
416
+ the default `padx` and `pady` used by `section()`, `grid()`, `paned()`, grid
417
+ `widget()` placements, and nested frames. Explicit `padx`/`pady` arguments still
418
+ override the default.
419
+
420
+ For finer control, pass `padx` or `pady` directly to the constructor:
421
+
422
+ ```python
423
+ Layout(padx=tokens.SPACE[4], pady=tokens.SPACE[3]).section("a")
424
+ ```
425
+
426
+ ### Nested frames
427
+
428
+ A `Layout` can be placed inside a named frame to group widgets visually without
429
+ mixing pack and grid in the same parent. The inner layout owns its own spacing
430
+ and may use any layout style (pack sections, grid, paned, or further nested
431
+ frames).
432
+
433
+ ```python
434
+ inner = Layout().section("a", "b")
435
+ outer = Layout().section("title").frame("group", inner).section("ok")
436
+ app.run(layout=outer)
437
+ ```
438
+
439
+ `frame(name, layout, side=..., fill=..., expand=..., padx=..., pady=...)` packs
440
+ the inner layout as a single block. The frame itself is registered under `name`,
441
+ so it can also be placed inside a grid cell:
442
+
443
+ ```python
444
+ outer = (
445
+ Layout()
446
+ .grid()
447
+ .widget("label")
448
+ .widget("group", sticky="nsew")
449
+ .end_grid()
450
+ .frame("group", Layout().section("a", "b"))
451
+ )
452
+ ```
453
+
454
+ ### Paired layout
455
+
456
+ `Layout.paired(left, right, ...)` places two widgets side by side in a
457
+ single two-column frame. It is a lighter alternative to `app.paned` for
458
+ diff/compare views:
459
+
460
+ ```python
461
+ layout = (
462
+ Layout()
463
+ .section("info")
464
+ .paired(
465
+ "left_text",
466
+ "right_text",
467
+ weight=(1, 1),
468
+ fill=Fill.BOTH,
469
+ expand=True,
470
+ sync_yscroll=True,
471
+ )
472
+ )
473
+ ```
474
+
475
+ Options:
476
+
477
+ | Argument | Default | Description |
478
+ |----------|---------|-------------|
479
+ | `weight` | `(1, 1)` | Two-column `(left, right)` grid weights |
480
+ | `fill` | `"x"` | Frame `fill` passed to `pack` |
481
+ | `expand` | `False` | Frame `expand` passed to `pack` |
482
+ | `sync_yscroll` | `False` | Wire the two widgets' y-scroll commands together |
483
+ | `side`, `padx`, `pady`, `anchor` | — | Standard `Layout` block placement options |
484
+
485
+ When `sync_yscroll=True` and both widgets are `@app.text` widgets, scrolling
486
+ either pane moves the other. For text widgets the same effect can also be
487
+ achieved per-widget with `@app.text(..., sync_yscroll_with="other")`; paired
488
+ layout provides a layout-level switch that works without editing widget
489
+ declarations.
490
+
491
+ ### Cluster layout
492
+
493
+ `Layout.cluster()` is a wrapping flow: widgets are not stretched to fill a
494
+ column; each keeps the width implied by its content or `width=` and they lay
495
+ out left to right, wrapping to the next row whenever the next widget no longer
496
+ fits in the remaining frame width. Widgets in the same row are vertically
497
+ centered on their midline, so a tall `entry` aligns cleanly with a shorter
498
+ `button` or `checkbutton`. The gap inherits the layout spacing by default.
499
+
500
+ ```python
501
+ TAGS = ["python", "tkinter", "async", "uv", "type hints"]
502
+ for tag in TAGS:
503
+ @app.button(tag, label=f"#{tag}")
504
+ def on_tag(values, tag=tag):
505
+ return {"msg": f"Selected: {tag}"}
506
+
507
+ app.run(layout=Layout(spacing=2).status("msg").cluster(*TAGS))
508
+ ```
509
+
510
+ Cluster is ideal for tag clouds, toolbars, and filter UIs. The layout recomputes
511
+ rows automatically when the window is resized. Pass `gap=...` to override the
512
+ spacing default, and `side`/`fill`/`expand` to control how the cluster frame is
513
+ packed.
514
+
192
515
  ### Fluent DSL
193
516
 
194
517
  **Pack sections:**
@@ -229,7 +552,7 @@ Grid builder methods:
229
552
  | `row_minsize(row, px)` | Row minimum height |
230
553
  | `end_grid()` | Return to Layout chain |
231
554
 
232
- Use `col_weight(0, 0).col_weight(1, 1)` to set column 0 → weight 0 and column 1 → weight 1. This is explicit and less error-prone than index-position bulk lists.
555
+ Use `col_weight(0, 0).col_weight(1, 1)` to set column 0 → weight 0 and column 1 → weight 1.
233
556
 
234
557
  ### With-block (context manager)
235
558
 
@@ -271,19 +594,49 @@ app.run(layout=b.build())
271
594
  | `@app.message(name, width=..., auto_width=...)` | tk.Label (auto-wrap) | — | `str` or `dict` |
272
595
  | `@app.button(name, label=..., font=..., enabled_if=...)` | ttk.Button | entry values `dict` | `dict` |
273
596
  | `@app.job(name)` | async callable | entry values `dict` | `dict` |
274
- | `@app.entry(name, placeholder=..., show=..., font=..., padding=..., width=...)` | ttk.Entry | `str` | `dict` |
597
+ | `@app.entry(name, placeholder=..., show=..., font=..., padding=..., width=..., events=...)` | ttk.Entry | `str` | `dict` |
275
598
  | `@app.checkbutton(name, text=..., font=...)` | ttk.Checkbutton | `bool` | `dict` |
276
599
  | `@app.radiobutton(name, text=..., value=..., group=..., font=...)` | ttk.Radiobutton | selected value `str` | `dict` |
277
- | `@app.combobox(name, values=..., readonly=..., font=...)` | ttk.Combobox | selected value `str` | `dict` |
600
+ | `@app.combobox(name, values=..., values_key=..., readonly=..., font=...)` | ttk.Combobox | selected value `str` | `dict` |
278
601
  | `@app.menubar(name)` | tk.Menu (window menubar) | — | menu item list |
602
+ | `@app.filepicker(name, mode=..., label=..., title=..., filetypes=..., ...)` | ttk.Button → tkinter.filedialog | selected path(s) `str`, `list[str]`, or `None` | `dict` |
279
603
  | `@app.text(name, width=..., height=..., font=...)` | tk.Text | full content `str` | `dict` |
280
604
  | `@app.scale(name, from_=..., to=..., orient=...)` | ttk.Scale | value `str` | `dict` |
281
605
  | `@app.spinbox(name, from_=..., to=..., values=..., font=...)` | ttk.Spinbox | value `str` | `dict` |
282
- | `@app.listbox(name, items=..., selectmode=..., font=...)` | tk.Listbox | selected item `str` | `dict` |
606
+ | `@app.listbox(name, items=..., items_key=..., selectmode=..., font=..., events=...)` | tk.Listbox | selected index `int` (`-1` if none) | `dict` |
283
607
  | `@app.canvas(name, width=..., height=...)` | tk.Canvas | — | — |
284
608
 
285
609
  `@app.status` sets schema / accessible `role="status"` metadata. It is **not** an ARIA live region yet (planned for a later release). Prefer it for operation feedback labels; use `@app.label` for static or high-frequency mirror text.
286
610
 
611
+ ### File picker
612
+
613
+ `@app.filepicker` creates a button that opens a tkinter file dialog.
614
+ The selected path(s) are passed to the callback; `None` on cancel.
615
+
616
+ ```python
617
+ @app.filepicker("open_file", mode="open", label="Open file",
618
+ title="Open file", filetypes=[("Text files", "*.txt")])
619
+ def pick_open_file(path: str | None) -> dict[str, Any]:
620
+ return {"open_path": path}
621
+ ```
622
+
623
+ Modes: `"open"` (default), `"open_multiple"`, `"save"`, `"directory"`.
624
+
625
+ A filepicker can also be invoked from a menubar item by using its name
626
+ as the `command`:
627
+
628
+ ```python
629
+ @app.filepicker("m_open", mode="open", title="Open file")
630
+ def m_open(path):
631
+ return {"open_path": path}
632
+
633
+ @app.menubar("menu")
634
+ def menu_bar():
635
+ return [{"label": "File", "items": [
636
+ {"label": "Open...", "command": "m_open"},
637
+ ]}]
638
+ ```
639
+
287
640
  `app.run(stages=..., tabposition=...)` and `@app.stages` provide state-driven screen switching (one visible stage at a time). Theme helpers (`apply_theme`, `tokens`, layout chrome) ship in the package root — see `examples/header_demo.py`.
288
641
 
289
642
  Common options:
@@ -323,28 +676,33 @@ accept raw strings too.
323
676
  | `Relief` | `Relief.FLAT/RAISED/SUNKEN/GROOVE/RIDGE/SOLID` | border style |
324
677
  | `Justify` | `Justify.LEFT/RIGHT/CENTER` | text alignment |
325
678
  | `SelectMode` | `SelectMode.SINGLE/BROWSE/MULTIPLE/EXTENDED` | listbox mode |
679
+ | `EventSeq` | `EventSeq.RETURN/ESCAPE/BACKSPACE/DELETE/TAB/...` | event sequences for bindings |
680
+ | `EventSeq` (mouse) | `EventSeq.BUTTON_1/2/3`, `DOUBLE_BUTTON_1/2/3`, `PRIMARY_DOUBLE_CLICK` | mouse event sequences |
681
+ | `EventSeq` (virtual) | `EventSeq.LISTBOX_SELECT/COMBOBOX_SELECTED/NOTEBOOK_TAB_CHANGED/...` | virtual events |
326
682
 
327
683
  ---
328
684
 
329
- ## Schema Export (Agent/LLM)
685
+ ## Schema Export
686
+
687
+ `app.schema()` returns a JSON-compatible snapshot of registered widgets
688
+ (`name`, `kind`, `label`, `role`, `description`, plus kind-specific fields).
330
689
 
331
690
  ```python
332
- @label("temperature")
333
- def t(): return "25°C"
691
+ @app.label("temperature")
692
+ def t():
693
+ return "25°C"
334
694
 
335
695
  app.schema()
336
696
  # → {"title": "...", "widgets": [{"name": "temperature", "kind": "label", ...}]}
337
697
  ```
338
698
 
339
- Output is JSON-compatible and can be used as a foundation for agent tools and LLM function definitions.
340
-
341
699
  ---
342
700
 
343
701
  ## Layout debug
344
702
 
345
703
  After widgets are built, `app.debug_layout()` returns JSON-compatible geometry
346
704
  and pack/grid info for every registered widget (useful for clipping, minsize,
347
- and layout regressions; also handy to hand to an agent).
705
+ and layout regressions).
348
706
 
349
707
  ```python
350
708
  app.run(layout=["msg", "go"]) # or build via tests / custom runner
@@ -380,7 +738,9 @@ uv run python examples/widget_gallery.py # all widget types
380
738
  uv run python examples/header_demo.py # Layout.header / .status chrome
381
739
  uv run python examples/combobox_demo.py # ttk.Combobox
382
740
  uv run python examples/menubar_demo.py # menubar
741
+ uv run python examples/filepicker_demo.py # file picker
383
742
  uv run python examples/disk_usage_flat_async.py # ncdu-style viewer (async)
743
+ uv run python examples/paired_demo.py # side-by-side paired layout with y-scroll sync
384
744
  ```
385
745
 
386
746
  ---