nextpytk 0.4.2__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.2
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
@@ -400,6 +400,118 @@ Each name gets its own pack-based section. Extra kwargs forwarded to `section()`
400
400
  Layout.from_list(["a", "b"], fill="both", expand=True)
401
401
  ```
402
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
+
403
515
  ### Fluent DSL
404
516
 
405
517
  **Pack sections:**
@@ -487,6 +599,7 @@ app.run(layout=b.build())
487
599
  | `@app.radiobutton(name, text=..., value=..., group=..., font=...)` | ttk.Radiobutton | selected value `str` | `dict` |
488
600
  | `@app.combobox(name, values=..., values_key=..., readonly=..., font=...)` | ttk.Combobox | selected value `str` | `dict` |
489
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` |
490
603
  | `@app.text(name, width=..., height=..., font=...)` | tk.Text | full content `str` | `dict` |
491
604
  | `@app.scale(name, from_=..., to=..., orient=...)` | ttk.Scale | value `str` | `dict` |
492
605
  | `@app.spinbox(name, from_=..., to=..., values=..., font=...)` | ttk.Spinbox | value `str` | `dict` |
@@ -495,6 +608,35 @@ app.run(layout=b.build())
495
608
 
496
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.
497
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
+
498
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`.
499
641
 
500
642
  Common options:
@@ -596,7 +738,9 @@ uv run python examples/widget_gallery.py # all widget types
596
738
  uv run python examples/header_demo.py # Layout.header / .status chrome
597
739
  uv run python examples/combobox_demo.py # ttk.Combobox
598
740
  uv run python examples/menubar_demo.py # menubar
741
+ uv run python examples/filepicker_demo.py # file picker
599
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
600
744
  ```
601
745
 
602
746
  ---
@@ -376,6 +376,118 @@ Each name gets its own pack-based section. Extra kwargs forwarded to `section()`
376
376
  Layout.from_list(["a", "b"], fill="both", expand=True)
377
377
  ```
378
378
 
379
+ ### Layout spacing
380
+
381
+ The default block padding comes from the design token `SPACE[1]` (4 px). Pass
382
+ `spacing=...` to a `Layout` constructor to change the default padding for every
383
+ block it creates:
384
+
385
+ ```python
386
+ from nextpytk import tokens
387
+
388
+ layout = Layout(spacing=2).section("a").section("b").grid().widget("c").end_grid()
389
+ ```
390
+
391
+ `spacing` accepts a key from `nextpytk.tokens.SPACE` (1, 2, 3, 4, 6, 8). It sets
392
+ the default `padx` and `pady` used by `section()`, `grid()`, `paned()`, grid
393
+ `widget()` placements, and nested frames. Explicit `padx`/`pady` arguments still
394
+ override the default.
395
+
396
+ For finer control, pass `padx` or `pady` directly to the constructor:
397
+
398
+ ```python
399
+ Layout(padx=tokens.SPACE[4], pady=tokens.SPACE[3]).section("a")
400
+ ```
401
+
402
+ ### Nested frames
403
+
404
+ A `Layout` can be placed inside a named frame to group widgets visually without
405
+ mixing pack and grid in the same parent. The inner layout owns its own spacing
406
+ and may use any layout style (pack sections, grid, paned, or further nested
407
+ frames).
408
+
409
+ ```python
410
+ inner = Layout().section("a", "b")
411
+ outer = Layout().section("title").frame("group", inner).section("ok")
412
+ app.run(layout=outer)
413
+ ```
414
+
415
+ `frame(name, layout, side=..., fill=..., expand=..., padx=..., pady=...)` packs
416
+ the inner layout as a single block. The frame itself is registered under `name`,
417
+ so it can also be placed inside a grid cell:
418
+
419
+ ```python
420
+ outer = (
421
+ Layout()
422
+ .grid()
423
+ .widget("label")
424
+ .widget("group", sticky="nsew")
425
+ .end_grid()
426
+ .frame("group", Layout().section("a", "b"))
427
+ )
428
+ ```
429
+
430
+ ### Paired layout
431
+
432
+ `Layout.paired(left, right, ...)` places two widgets side by side in a
433
+ single two-column frame. It is a lighter alternative to `app.paned` for
434
+ diff/compare views:
435
+
436
+ ```python
437
+ layout = (
438
+ Layout()
439
+ .section("info")
440
+ .paired(
441
+ "left_text",
442
+ "right_text",
443
+ weight=(1, 1),
444
+ fill=Fill.BOTH,
445
+ expand=True,
446
+ sync_yscroll=True,
447
+ )
448
+ )
449
+ ```
450
+
451
+ Options:
452
+
453
+ | Argument | Default | Description |
454
+ |----------|---------|-------------|
455
+ | `weight` | `(1, 1)` | Two-column `(left, right)` grid weights |
456
+ | `fill` | `"x"` | Frame `fill` passed to `pack` |
457
+ | `expand` | `False` | Frame `expand` passed to `pack` |
458
+ | `sync_yscroll` | `False` | Wire the two widgets' y-scroll commands together |
459
+ | `side`, `padx`, `pady`, `anchor` | — | Standard `Layout` block placement options |
460
+
461
+ When `sync_yscroll=True` and both widgets are `@app.text` widgets, scrolling
462
+ either pane moves the other. For text widgets the same effect can also be
463
+ achieved per-widget with `@app.text(..., sync_yscroll_with="other")`; paired
464
+ layout provides a layout-level switch that works without editing widget
465
+ declarations.
466
+
467
+ ### Cluster layout
468
+
469
+ `Layout.cluster()` is a wrapping flow: widgets are not stretched to fill a
470
+ column; each keeps the width implied by its content or `width=` and they lay
471
+ out left to right, wrapping to the next row whenever the next widget no longer
472
+ fits in the remaining frame width. Widgets in the same row are vertically
473
+ centered on their midline, so a tall `entry` aligns cleanly with a shorter
474
+ `button` or `checkbutton`. The gap inherits the layout spacing by default.
475
+
476
+ ```python
477
+ TAGS = ["python", "tkinter", "async", "uv", "type hints"]
478
+ for tag in TAGS:
479
+ @app.button(tag, label=f"#{tag}")
480
+ def on_tag(values, tag=tag):
481
+ return {"msg": f"Selected: {tag}"}
482
+
483
+ app.run(layout=Layout(spacing=2).status("msg").cluster(*TAGS))
484
+ ```
485
+
486
+ Cluster is ideal for tag clouds, toolbars, and filter UIs. The layout recomputes
487
+ rows automatically when the window is resized. Pass `gap=...` to override the
488
+ spacing default, and `side`/`fill`/`expand` to control how the cluster frame is
489
+ packed.
490
+
379
491
  ### Fluent DSL
380
492
 
381
493
  **Pack sections:**
@@ -463,6 +575,7 @@ app.run(layout=b.build())
463
575
  | `@app.radiobutton(name, text=..., value=..., group=..., font=...)` | ttk.Radiobutton | selected value `str` | `dict` |
464
576
  | `@app.combobox(name, values=..., values_key=..., readonly=..., font=...)` | ttk.Combobox | selected value `str` | `dict` |
465
577
  | `@app.menubar(name)` | tk.Menu (window menubar) | — | menu item list |
578
+ | `@app.filepicker(name, mode=..., label=..., title=..., filetypes=..., ...)` | ttk.Button → tkinter.filedialog | selected path(s) `str`, `list[str]`, or `None` | `dict` |
466
579
  | `@app.text(name, width=..., height=..., font=...)` | tk.Text | full content `str` | `dict` |
467
580
  | `@app.scale(name, from_=..., to=..., orient=...)` | ttk.Scale | value `str` | `dict` |
468
581
  | `@app.spinbox(name, from_=..., to=..., values=..., font=...)` | ttk.Spinbox | value `str` | `dict` |
@@ -471,6 +584,35 @@ app.run(layout=b.build())
471
584
 
472
585
  `@app.status` sets schema / accessible `role="status"` metadata. It is **not** an ARIA live region yet (planned for a later release). Prefer it for operation feedback labels; use `@app.label` for static or high-frequency mirror text.
473
586
 
587
+ ### File picker
588
+
589
+ `@app.filepicker` creates a button that opens a tkinter file dialog.
590
+ The selected path(s) are passed to the callback; `None` on cancel.
591
+
592
+ ```python
593
+ @app.filepicker("open_file", mode="open", label="Open file",
594
+ title="Open file", filetypes=[("Text files", "*.txt")])
595
+ def pick_open_file(path: str | None) -> dict[str, Any]:
596
+ return {"open_path": path}
597
+ ```
598
+
599
+ Modes: `"open"` (default), `"open_multiple"`, `"save"`, `"directory"`.
600
+
601
+ A filepicker can also be invoked from a menubar item by using its name
602
+ as the `command`:
603
+
604
+ ```python
605
+ @app.filepicker("m_open", mode="open", title="Open file")
606
+ def m_open(path):
607
+ return {"open_path": path}
608
+
609
+ @app.menubar("menu")
610
+ def menu_bar():
611
+ return [{"label": "File", "items": [
612
+ {"label": "Open...", "command": "m_open"},
613
+ ]}]
614
+ ```
615
+
474
616
  `app.run(stages=..., tabposition=...)` and `@app.stages` provide state-driven screen switching (one visible stage at a time). Theme helpers (`apply_theme`, `tokens`, layout chrome) ship in the package root — see `examples/header_demo.py`.
475
617
 
476
618
  Common options:
@@ -572,7 +714,9 @@ uv run python examples/widget_gallery.py # all widget types
572
714
  uv run python examples/header_demo.py # Layout.header / .status chrome
573
715
  uv run python examples/combobox_demo.py # ttk.Combobox
574
716
  uv run python examples/menubar_demo.py # menubar
717
+ uv run python examples/filepicker_demo.py # file picker
575
718
  uv run python examples/disk_usage_flat_async.py # ncdu-style viewer (async)
719
+ uv run python examples/paired_demo.py # side-by-side paired layout with y-scroll sync
576
720
  ```
577
721
 
578
722
  ---
@@ -130,7 +130,18 @@ PyPI: Trusted Publishing on `v*` tag push (`.github/workflows/publish.yml`).
130
130
 
131
131
  ---
132
132
 
133
- ## Near term (post-0.4.2)
133
+ ## Snapshot (v0.4.3) — filepicker + layout blocks
134
+
135
+ - Added `@app.filepicker` (`filedialog` wrapper; `mode=` open/save/directory/open_multiple).
136
+ - Added `Layout(spacing=...)` for a layout-wide SPACE token default (padx/pady inheritance).
137
+ - Added `Layout.frame(...)` for nested layouts (`Layout` inside `Layout`).
138
+ - Added `Layout.cluster(...)` wrapping flow: widgets keep content/`width=` sizes and wrap left-to-right.
139
+ - Added `Layout.paired(left, right, ..., sync_yscroll=)` for side-by-side panes with optional text y-scroll sync.
140
+ - Docs/examples/tests for the above (`filepicker_demo`, `nested_frame_demo`, `cluster_demo`, `paired_demo`).
141
+
142
+ ---
143
+
144
+ ## Near term (post-0.4.3)
134
145
 
135
146
  ### A11y
136
147
 
@@ -149,19 +160,21 @@ PyPI: Trusted Publishing on `v*` tag push (`.github/workflows/publish.yml`).
149
160
  - [x] Per-widget `bind` for entry and listbox (`events=` on `@app.entry` and `@app.listbox`)
150
161
  - [ ] Per-widget `bind` for other widgets (`text`, `combobox`, `treeview`, `button`, etc.)
151
162
  - [x] listbox callback value design: keep selection index (unify with `treeview`)
163
+ - [x] `@app.filepicker` — `filedialog` wrapper (schema/tool naming still open)
152
164
 
153
165
  ### Layout DSL
154
166
 
155
167
  - [x] Deprecate index-order plural methods `col_weights`/`row_weights`/`col_minsizes`/`row_minsizes` in 0.4.1 (remove in 0.5.0).
156
168
  - [ ] CSS-grid-inspired layout API for 0.5.0: explicit `grid-template-columns` / `grid-template-rows` strings (e.g. `"1fr 2fr"`, `"auto 1fr"`), area-based placement, and gap tokens. Reduce misuse by making rows/columns and widget placement visually aligned in one declaration.
157
- - [ ] Nested frames (`Layout` inside `Layout`)
158
- - [ ] Global default for `padx`/`pady`
169
+ - [x] Nested frames (`Layout` inside `Layout`) via `Layout.frame(...)`
170
+ - [x] Global default for `padx`/`pady` via `Layout(spacing=...)`
171
+ - [x] `Layout.cluster(...)` wrapping flow; `Layout.paired(..., sync_yscroll=)`
159
172
 
160
173
  ### Agent / LLM integration
161
174
 
162
175
  - [ ] Expose `schema()` as Function Calling definition (bind `sequence`, treeview column defs, etc.)
163
176
  - [ ] `@agent_tool` integration (GUI operations as agent vocabulary)
164
- - [ ] `@app.filepicker` — `filedialog` wrapper (tool name in schema)
177
+ - [ ] Surface `@app.filepicker` in `schema()` / tool vocabulary
165
178
 
166
179
  ---
167
180
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nextpytk"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "Accessible, declarative Tkinter applications from ordinary Python functions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -37,6 +37,8 @@ from nextpytk.types import (
37
37
  ComboboxOptions,
38
38
  EntryOptions,
39
39
  EventSeq,
40
+ FilepickerCallback,
41
+ FilepickerOptions,
40
42
  FillLike,
41
43
  LabelOptions,
42
44
  EntryEventHandler,
@@ -103,6 +105,9 @@ ButtonCallback = (
103
105
  )
104
106
  BindCallback = ButtonCallback # bind: same signature as button
105
107
 
108
+ # filepicker: receives selected path(s) or None, returns state dict
109
+ FilepickerCallback = Callable[[str | list[str] | None], dict[str, Any]]
110
+
106
111
  # entry / text / scale / spinbox: receives value str (optional), returns state dict
107
112
  ValueCallback = Callable[[str], dict[str, Any]] | Callable[[], dict[str, Any]]
108
113
 
@@ -186,7 +191,7 @@ class ViewContext:
186
191
  "label", "status", "message", "button", "bind", "entry",
187
192
  "checkbutton", "radiobutton", "text", "scale", "spinbox",
188
193
  "listbox", "combobox", "treeview", "paned", "progressbar", "canvas",
189
- "menubar",
194
+ "menubar", "filepicker",
190
195
  })
191
196
 
192
197
  def pane(self, pane_id: str) -> _PaneContext:
@@ -248,6 +253,7 @@ class TkApp:
248
253
  self._treeview_inner: dict[str, ttk.Treeview] = {}
249
254
  self._treeview_row_cache: dict[str, tuple[Any, ...]] = {}
250
255
  self._text_inner: dict[str, tk.Text] = {}
256
+ self._text_scrollbars: dict[str, ttk.Scrollbar] = {}
251
257
  self._text_scroll_sync: dict[str, str] = {}
252
258
  self._paned_inner: dict[str, ttk.Panedwindow] = {}
253
259
  self._pane_frames: dict[str, tk.Widget] = {}
@@ -295,6 +301,7 @@ class TkApp:
295
301
  self._grid_pack_jobs.clear()
296
302
  self._menubar_submenus.clear()
297
303
  self._text_inner.clear()
304
+ self._text_scrollbars.clear()
298
305
  self._text_scroll_sync.clear()
299
306
  self._a11y_last_toggle.clear()
300
307
  self._current_view = None
@@ -1463,6 +1470,9 @@ class TkApp:
1463
1470
  spec = self._spec(command_name)
1464
1471
  if spec is None or spec.on_click is None:
1465
1472
  return
1473
+ if spec.kind == "filepicker":
1474
+ self._invoke_filepicker(spec)
1475
+ return
1466
1476
  values = self._entry_values_dict()
1467
1477
  self._state[command_name] = label
1468
1478
  result = self._dispatch(command_name, spec.on_click, values)
@@ -1631,6 +1641,60 @@ class TkApp:
1631
1641
  return fn
1632
1642
  return decorator
1633
1643
 
1644
+ def filepicker(
1645
+ self,
1646
+ name: str,
1647
+ **options: Unpack[FilepickerOptions],
1648
+ ) -> Callable[[Callable[[Any], dict[str, Any]]], Callable[[Any], dict[str, Any]]]:
1649
+ """Register a file picker button.
1650
+
1651
+ Clicking the button opens a tkinter file dialog. The selected path(s)
1652
+ are passed to the decorated callback, which returns a state update
1653
+ dict. ``mode`` selects the dialog type:
1654
+
1655
+ - ``"open"`` (default): single file open via ``askopenfilename``.
1656
+ - ``"open_multiple"``: multiple files via ``askopenfilenames``.
1657
+ - ``"save"``: save file via ``asksaveasfilename``.
1658
+ - ``"directory"``: directory via ``askdirectory``.
1659
+
1660
+ ``title``, ``initialdir``, ``initialfile``, ``filetypes``, and
1661
+ ``defaultextension`` are forwarded to the underlying dialog. For
1662
+ ``filetypes`` use a sequence of ``("label", "*.ext")`` or
1663
+ ``("label", "*.ext1 *.ext2")`` tuples.
1664
+
1665
+ The callback receives the selected path string, a list of strings for
1666
+ ``"open_multiple"``, or ``None`` when the user cancels.
1667
+
1668
+ Example::
1669
+
1670
+ @app.filepicker("open_path", mode="open", title="Open file")
1671
+ def pick_open_path(path: str | None) -> dict[str, Any]:
1672
+ return {"open_path": path}
1673
+ """
1674
+ def decorator(fn: Callable[[Any], dict[str, Any]]) -> Callable[[Any], dict[str, Any]]:
1675
+ mode = options.get("mode", "open")
1676
+ label = options.get("label", "")
1677
+ description = options.get("description")
1678
+ enabled_if = options.get("enabled_if")
1679
+ takefocus = options.get("takefocus")
1680
+ primary = options.get("primary", False)
1681
+ font = options.get("font")
1682
+ extras: dict[str, Any] = {
1683
+ "mode": mode,
1684
+ "style": "Primary.TButton" if primary else "Secondary.TButton",
1685
+ }
1686
+ for opt in ("title", "initialdir", "initialfile", "filetypes", "defaultextension"):
1687
+ value = options.get(opt)
1688
+ if value is not None:
1689
+ extras[opt] = value
1690
+ self._add_spec(WidgetSpec(
1691
+ name=name, kind="filepicker", label_text=label, role="button",
1692
+ description=description, on_click=fn, enabled_if=enabled_if,
1693
+ extras=self._widget_extras(extras, takefocus=takefocus),
1694
+ ))
1695
+ return fn
1696
+ return decorator
1697
+
1634
1698
  def button(
1635
1699
  self,
1636
1700
  name: str,
@@ -2461,7 +2525,9 @@ class TkApp:
2461
2525
  value = result
2462
2526
  elif isinstance(result, dict):
2463
2527
  value = result.get(spec.name, value)
2464
- tk_w.configure(text=str(value)) # type: ignore[call-arg]
2528
+ target = str(value)
2529
+ if str(tk_w.cget("text")) != target:
2530
+ tk_w.configure(text=target) # type: ignore[call-arg]
2465
2531
  elif spec.kind == "text":
2466
2532
  self._sync_text_widget(spec)
2467
2533
  elif spec.kind == "listbox" and spec.extras.get("items_key") is not None:
@@ -2535,7 +2601,9 @@ class TkApp:
2535
2601
  tk_w = self._tk_widgets.get(spec.name)
2536
2602
  if tk_w is None:
2537
2603
  continue
2538
- tk_w.configure(text=str(update[spec.name])) # type: ignore[call-arg]
2604
+ target = str(update[spec.name])
2605
+ if str(tk_w.cget("text")) != target:
2606
+ tk_w.configure(text=target) # type: ignore[call-arg]
2539
2607
  elif spec.kind == "text" and spec.name in update:
2540
2608
  self._sync_text_widget(spec)
2541
2609
 
@@ -2816,6 +2884,7 @@ class TkApp:
2816
2884
  "label": self._build_label,
2817
2885
  "message": self._build_message,
2818
2886
  "button": self._build_button,
2887
+ "filepicker": self._build_filepicker,
2819
2888
  "entry": self._build_entry,
2820
2889
  "checkbutton": self._build_checkbutton,
2821
2890
  "radiobutton": self._build_radiobutton,
@@ -2956,11 +3025,28 @@ class TkApp:
2956
3025
  if text_a is None or text_b is None:
2957
3026
  continue
2958
3027
 
2959
- def _sync_from_a(*_args: Any, source: tk.Text = text_a, target: tk.Text = text_b) -> None:
2960
- target.yview_moveto(source.yview()[0])
3028
+ sb_a = self._text_scrollbars.get(name_a)
3029
+ sb_b = self._text_scrollbars.get(name_b)
2961
3030
 
2962
- def _sync_from_b(*_args: Any, source: tk.Text = text_b, target: tk.Text = text_a) -> None:
3031
+ def _sync_from_a(
3032
+ *args: Any,
3033
+ source: tk.Text = text_a,
3034
+ target: tk.Text = text_b,
3035
+ sb: ttk.Scrollbar | None = sb_a,
3036
+ ) -> None:
2963
3037
  target.yview_moveto(source.yview()[0])
3038
+ if sb is not None:
3039
+ sb.set(*args)
3040
+
3041
+ def _sync_from_b(
3042
+ *args: Any,
3043
+ source: tk.Text = text_b,
3044
+ target: tk.Text = text_a,
3045
+ sb: ttk.Scrollbar | None = sb_b,
3046
+ ) -> None:
3047
+ target.yview_moveto(source.yview()[0])
3048
+ if sb is not None:
3049
+ sb.set(*args)
2964
3050
 
2965
3051
  text_a.configure(yscrollcommand=_sync_from_a)
2966
3052
  text_b.configure(yscrollcommand=_sync_from_b)
@@ -3176,6 +3262,51 @@ class TkApp:
3176
3262
  fn = spec.on_click
3177
3263
  w.configure(command=lambda s=spec, f=fn: self._on_button_click(s, f))
3178
3264
 
3265
+ def _invoke_filepicker(self, spec: WidgetSpec) -> None:
3266
+ """Open the file dialog for a filepicker spec and dispatch the result."""
3267
+ import tkinter.filedialog as fd
3268
+
3269
+ mode = spec.extras.get("mode", "open")
3270
+ dialog_kw: dict[str, Any] = {}
3271
+ for opt in ("title", "initialdir", "initialfile", "filetypes", "defaultextension"):
3272
+ if opt in spec.extras:
3273
+ dialog_kw[opt] = spec.extras[opt]
3274
+
3275
+ if mode == "open":
3276
+ result = fd.askopenfilename(**dialog_kw)
3277
+ elif mode == "open_multiple":
3278
+ result = fd.askopenfilenames(**dialog_kw)
3279
+ elif mode == "save":
3280
+ result = fd.asksaveasfilename(**dialog_kw)
3281
+ elif mode == "directory":
3282
+ result = fd.askdirectory(**dialog_kw)
3283
+ else:
3284
+ raise ValueError(f"unknown filepicker mode {mode!r}")
3285
+ if result == "" or result == () or result is None:
3286
+ result = None
3287
+ fn = spec.on_click
3288
+ if fn is None:
3289
+ return
3290
+ out = self._dispatch(spec.name, fn, result)
3291
+ if isinstance(out, dict) and out:
3292
+ self._apply_state(out)
3293
+
3294
+ def _build_filepicker(self, spec: WidgetSpec, master: tk.Misc) -> None:
3295
+ """Build a button that opens a file dialog when clicked."""
3296
+ style = spec.extras.get("style", "Secondary.TButton")
3297
+ overrides: dict[str, Any] = {}
3298
+ if "font" in spec.extras:
3299
+ overrides["font"] = spec.extras["font"]
3300
+ if overrides:
3301
+ style = self._derive_ttk_style(
3302
+ style, f"Unique.{style}.{spec.name}", overrides
3303
+ )
3304
+ w = ttk.Button(master, text=spec.label_text or spec.name, style=style)
3305
+ if "state" in spec.extras:
3306
+ w.configure(state=spec.extras["state"])
3307
+ self._tk_widgets[spec.name] = w
3308
+ w.configure(command=lambda s=spec: self._invoke_filepicker(s))
3309
+
3179
3310
  def _build_entry(self, spec: WidgetSpec, master: tk.Misc) -> None:
3180
3311
  e = spec.extras
3181
3312
  var = tk.StringVar(value="")
@@ -3301,6 +3432,7 @@ class TkApp:
3301
3432
  scroll.grid(row=0, column=1, sticky="ns")
3302
3433
  self._tk_widgets[spec.name] = container
3303
3434
  self._text_inner[spec.name] = w
3435
+ self._text_scrollbars[spec.name] = scroll
3304
3436
 
3305
3437
  tags: dict[str, dict[str, Any]] | None = e.get("tags")
3306
3438
  if tags:
@@ -3972,6 +4104,11 @@ class TkApp:
3972
4104
  d["value"] = self._state.get(sk, 0)
3973
4105
  if w.kind == "entry":
3974
4106
  d["placeholder_as_hint"] = w.placeholder_as_hint
4107
+ if w.kind == "filepicker":
4108
+ d["mode"] = w.extras.get("mode", "open")
4109
+ for opt in ("title", "initialdir", "initialfile", "filetypes", "defaultextension"):
4110
+ if opt in w.extras:
4111
+ d[opt] = w.extras[opt]
3975
4112
  if w.kind == "menubar":
3976
4113
  d["items"] = [
3977
4114
  {"label": i.get("label"), "command": i.get("command"),