nextpytk 0.2.0__py3-none-any.whl

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.
nextpytk/app.py ADDED
@@ -0,0 +1,1376 @@
1
+ """TkApp: Flask-inspired decorator API for tkinter.
2
+
3
+ Core idea:
4
+ - ``@app.label`` / ``@app.status`` / ``@app.button`` / ``@app.entry`` /
5
+ ``@app.checkbutton`` / ``@app.radiobutton`` / ``@app.text`` /
6
+ ``@app.scale`` / ``@app.spinbox`` / ``@app.listbox`` register slots.
7
+ - Python owns nothing but schema + callbacks. Widget objects live in tkinter.
8
+ - Each decorated function returns a dict that merges into app's state.
9
+ - Layout is injected separately via Layout (DI / IoC).
10
+ - All widgets surface as JSON schema via ``app.schema()`` for agent consumption.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import tkinter as tk
17
+ import tkinter.ttk as ttk
18
+ from collections.abc import Awaitable, Callable
19
+ from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
20
+
21
+ from nextpytk.types import FillLike, OrientLike, SelectModeLike, SideLike, StateLike
22
+ from nextpytk.widgets import WidgetSpec
23
+
24
+ if TYPE_CHECKING:
25
+ from nextpytk.layout import Layout, LayoutBuilder
26
+
27
+ # ── asyncio helper: wrapper around root.after for async scheduling ──
28
+ TkAppAfterHandle: TypeAlias = str # root.after returns a string id
29
+
30
+ # ── async job type alias ──
31
+ AsyncJob = Callable[..., Awaitable[dict[str, Any] | None]]
32
+
33
+ F = TypeVar("F", bound=Callable[..., Any])
34
+ NotebookTabChange = Callable[[str], dict[str, Any] | None]
35
+
36
+ # ── callback type aliases ──
37
+
38
+ # label/status: no arg, returns str or state dict
39
+ LabelCallback = Callable[[], str | dict[str, Any]]
40
+
41
+ # button: receives entry values dict, returns state dict
42
+ ButtonCallback = Callable[[dict[str, Any]], dict[str, Any]]
43
+
44
+ # entry / text / listbox / scale / spinbox: receives value str, returns state dict
45
+ ValueCallback = Callable[[str], dict[str, Any]]
46
+
47
+ # checkbutton: receives bool, returns state dict
48
+ BoolCallback = Callable[[bool], dict[str, Any]]
49
+
50
+ # radiobutton: receives selected value str, returns state dict
51
+
52
+
53
+ class ViewContext:
54
+ """Context manager for ``with app.view(name) as v:``.
55
+
56
+ Proxies all ``@app.*`` decorator methods and records registered
57
+ widget names in ``app._view_widgets[name]``.
58
+ """
59
+
60
+ def __init__(self, app: TkApp, name: str):
61
+ self._app = app
62
+ self._name = name
63
+
64
+ def __enter__(self) -> ViewContext:
65
+ return self
66
+
67
+ def __exit__(self, *args: object) -> None:
68
+ pass
69
+
70
+ def label(self, name: str, **kw: Any):
71
+ self._app._view_widgets[self._name].append(name)
72
+ return self._app.label(name, **kw)
73
+
74
+ def status(self, name: str, **kw: Any):
75
+ self._app._view_widgets[self._name].append(name)
76
+ return self._app.status(name, **kw)
77
+
78
+ def message(self, name: str, **kw: Any):
79
+ self._app._view_widgets[self._name].append(name)
80
+ return self._app.message(name, **kw)
81
+
82
+ def button(self, name: str, **kw: Any):
83
+ self._app._view_widgets[self._name].append(name)
84
+ return self._app.button(name, **kw)
85
+
86
+ def entry(self, name: str, **kw: Any):
87
+ self._app._view_widgets[self._name].append(name)
88
+ return self._app.entry(name, **kw)
89
+
90
+ def checkbutton(self, name: str, **kw: Any):
91
+ self._app._view_widgets[self._name].append(name)
92
+ return self._app.checkbutton(name, **kw)
93
+
94
+ def radiobutton(self, name: str, **kw: Any):
95
+ self._app._view_widgets[self._name].append(name)
96
+ return self._app.radiobutton(name, **kw)
97
+
98
+ def text(self, name: str, **kw: Any):
99
+ self._app._view_widgets[self._name].append(name)
100
+ return self._app.text(name, **kw)
101
+
102
+ def scale(self, name: str, **kw: Any):
103
+ self._app._view_widgets[self._name].append(name)
104
+ return self._app.scale(name, **kw)
105
+
106
+ def spinbox(self, name: str, **kw: Any):
107
+ self._app._view_widgets[self._name].append(name)
108
+ return self._app.spinbox(name, **kw)
109
+
110
+ def listbox(self, name: str, **kw: Any):
111
+ self._app._view_widgets[self._name].append(name)
112
+ return self._app.listbox(name, **kw)
113
+
114
+ def canvas(self, name: str, **kw: Any):
115
+ self._app._view_widgets[self._name].append(name)
116
+ return self._app.canvas(name, **kw)
117
+
118
+
119
+ class TkApp:
120
+ """Flask-inspired Tk application with decorator API and DI layout.
121
+
122
+ Inversion of Control (IoC): decorators register intent,
123
+ Layout provides structure. Decoupled — classic IoC.
124
+ """
125
+
126
+ def __init__(self, title: str = "Flask-style decorator"):
127
+ self._title = title
128
+ self._widgets: list[WidgetSpec] = []
129
+ self._state: dict[str, Any] = {}
130
+ self._root: tk.Tk | None = None
131
+ self._tk_widgets: dict[str, tk.Widget] = {}
132
+ self._tk_vars: dict[str, tk.Variable] = {}
133
+ self._widget_masters: dict[str, tk.Misc] = {}
134
+ self._row_pack_jobs: list[tuple[tk.Frame, Any]] = []
135
+ self._grid_pack_jobs: list[tuple[tk.Frame, Any]] = []
136
+ self._view_widgets: dict[str, list[str]] = {}
137
+ self._view_layouts: dict[str, Layout] = {}
138
+ self._multiviews: dict[str, dict[str, Any]] = {}
139
+ self._current_view: str | None = None
140
+ self._event_loop: asyncio.AbstractEventLoop | None = None
141
+ self._jobs: dict[str, AsyncJob] = {}
142
+ self._background_tasks: set[asyncio.Task[Any]] = set()
143
+
144
+ def view(self, name: str, *, layout: Layout | None = None) -> ViewContext:
145
+ """Context manager for grouping widgets (e.g., a tab).
146
+
147
+ Usage::
148
+
149
+ with app.view("Settings") as v:
150
+ @v.status("msg")
151
+ def msg(): return "idle"
152
+ """
153
+ if name not in self._view_widgets:
154
+ self._view_widgets[name] = []
155
+ if layout is not None:
156
+ self._view_layouts[name] = layout
157
+ return ViewContext(self, name)
158
+
159
+ # ── public runtime helpers (for advanced custom runners) ──
160
+
161
+ @property
162
+ def title(self) -> str:
163
+ return self._title
164
+
165
+ def set_root(self, root: tk.Tk) -> None:
166
+ self._root = root
167
+
168
+ def clear_runtime(self) -> None:
169
+ self._tk_widgets.clear()
170
+ self._tk_vars.clear()
171
+ self._widget_masters.clear()
172
+ self._row_pack_jobs.clear()
173
+ self._grid_pack_jobs.clear()
174
+
175
+ def set_widget_master(self, widget_name: str, master: tk.Misc) -> None:
176
+ self._widget_masters[widget_name] = master
177
+
178
+ def view_widget_names(self, view_name: str) -> list[str]:
179
+ return list(self._view_widgets.get(view_name, []))
180
+
181
+ def view_layout(self, view_name: str) -> Layout | None:
182
+ return self._view_layouts.get(view_name)
183
+
184
+ def multiview(
185
+ self,
186
+ name: str,
187
+ *,
188
+ views: list[str],
189
+ toplevel_widgets: tuple[str, ...] = (),
190
+ initial_state: dict[str, Any] | None = None,
191
+ view_layouts: dict[str, Layout] | None = None,
192
+ center_kinds: set[str] | None = None,
193
+ on_tab_change: NotebookTabChange | None = None,
194
+ ) -> Callable[[F], F]:
195
+ """Declare a multiview configuration by name.
196
+
197
+ Example::
198
+
199
+ @app.multiview("main", views=["Home", "Settings"])
200
+ def _main_tabs():
201
+ pass
202
+
203
+ app.run(multiview="main")
204
+ """
205
+ cfg: dict[str, Any] = {
206
+ "views": list(views),
207
+ "toplevel_widgets": tuple(toplevel_widgets),
208
+ "initial_state": dict(initial_state) if initial_state else None,
209
+ "view_layouts": dict(view_layouts) if view_layouts else None,
210
+ "center_kinds": set(center_kinds) if center_kinds else None,
211
+ "on_tab_change": on_tab_change,
212
+ }
213
+ self._multiviews[name] = cfg
214
+
215
+ def decorator(fn: F) -> F:
216
+ return fn
217
+
218
+ return decorator
219
+
220
+ def build_widgets(self) -> None:
221
+ self._build_widgets()
222
+
223
+ def widget(self, name: str) -> tk.Widget | None:
224
+ return self._tk_widgets.get(name)
225
+
226
+ @property
227
+ def root(self) -> tk.Tk | None:
228
+ """Return the root Tk window (available after run)."""
229
+ return self._root
230
+
231
+ def widget_kind(self, name: str) -> str | None:
232
+ for w in self._widgets:
233
+ if w.name == name:
234
+ return w.kind
235
+ return None
236
+
237
+ def widget_specs(self, *, kind: str | None = None) -> list[WidgetSpec]:
238
+ if kind is None:
239
+ return list(self._widgets)
240
+ return [w for w in self._widgets if w.kind == kind]
241
+
242
+ def apply_state(self, update: dict[str, Any] | str) -> None:
243
+ self._apply_state(update)
244
+
245
+ def sync(self) -> None:
246
+ self._sync_widgets()
247
+ self._sync_widget_states()
248
+
249
+ def run_multiview(
250
+ self,
251
+ *,
252
+ views: list[str],
253
+ toplevel_widgets: tuple[str, ...] = (),
254
+ initial_state: dict[str, Any] | None = None,
255
+ view_layouts: dict[str, Layout] | None = None,
256
+ center_kinds: set[str] | None = None,
257
+ on_tab_change: NotebookTabChange | None = None,
258
+ on_ready: Callable[[TkApp], None] | None = None,
259
+ ) -> None:
260
+ """Build and run the app in a ttk.Notebook container.
261
+
262
+ This keeps examples close to ``app.run(layout=...)`` style while supporting
263
+ multi-view/tab UIs.
264
+ """
265
+ root = tk.Tk()
266
+ root.title(self._title)
267
+ self.set_root(root)
268
+ self.clear_runtime()
269
+
270
+ nb = ttk.Notebook(root)
271
+ frames: dict[str, tk.Frame] = {}
272
+ jobs_by_view: dict[str, tuple[Layout, list[Any], list[Any]]] = {}
273
+
274
+ layouts = dict(self._view_layouts)
275
+ if view_layouts:
276
+ for k, v in view_layouts.items():
277
+ if isinstance(v, list):
278
+ from nextpytk.layout import Layout
279
+ view_layouts[k] = Layout.from_list(v)
280
+ layouts.update(view_layouts)
281
+ unknown = sorted(set(layouts.keys()) - set(views))
282
+ if unknown:
283
+ raise ValueError(f"view_layouts includes unknown views: {', '.join(unknown)}")
284
+
285
+ for name in toplevel_widgets:
286
+ self.set_widget_master(name, root)
287
+
288
+ for view in views:
289
+ frame = tk.Frame(nb, name=f"tabframe_{view}")
290
+ frames[view] = frame
291
+ if view in layouts:
292
+ layout = layouts[view]
293
+ allowed = set(self.view_widget_names(view))
294
+ row_jobs, grid_jobs = layout.mount_frames_into(
295
+ self, frame, allowed_widgets=allowed)
296
+ jobs_by_view[view] = (layout, row_jobs, grid_jobs)
297
+ else:
298
+ for wname in self.view_widget_names(view):
299
+ self.set_widget_master(wname, frame)
300
+
301
+ self.build_widgets()
302
+
303
+ if initial_state:
304
+ self.apply_state(initial_state)
305
+
306
+ for name in toplevel_widgets:
307
+ w = self.widget(name)
308
+ if w is not None:
309
+ w.pack(fill="x", padx=5, pady=1)
310
+
311
+ nb.pack(fill="both", expand=True, padx=5, pady=5)
312
+ for view in views:
313
+ nb.add(frames[view], text=view)
314
+
315
+ centered = center_kinds or set()
316
+ for view in views:
317
+ if view in jobs_by_view:
318
+ layout, row_jobs, grid_jobs = jobs_by_view[view]
319
+ layout.pack_children_for(self, row_jobs, grid_jobs)
320
+ else:
321
+ self.pack_view_widgets(view, center_kinds=centered, fill="x", pady=2)
322
+
323
+ def _on_tab_changed(_event: tk.Event[tk.Misc] | None = None) -> None:
324
+ current = nb.select()
325
+ if not current:
326
+ return
327
+ view = str(nb.tab(current, "text"))
328
+ if on_tab_change is None:
329
+ return
330
+ update = on_tab_change(view)
331
+ if update:
332
+ self.apply_state(update)
333
+
334
+ nb.bind("<<NotebookTabChanged>>", _on_tab_changed)
335
+ if views:
336
+ nb.select(0)
337
+ _on_tab_changed()
338
+
339
+ if on_ready is not None:
340
+ on_ready(self)
341
+
342
+ self.draw_canvas_items()
343
+ self.sync()
344
+ root.mainloop()
345
+
346
+ # ── async job registration ──
347
+
348
+ def job(
349
+ self,
350
+ name: str,
351
+ *,
352
+ description: str | None = None,
353
+ ) -> Callable[[AsyncJob], AsyncJob]:
354
+ """Register an async coroutine as a named job.
355
+
356
+ Usage::
357
+
358
+ @app.job("scan")
359
+ async def scan():
360
+ result = await asyncio.to_thread(blocking_io)
361
+ return {"status": "done"}
362
+ """
363
+ def decorator(fn: AsyncJob) -> AsyncJob:
364
+ self._jobs[name] = fn
365
+ return fn
366
+ return decorator
367
+
368
+ def spawn(self, coro):
369
+ """Schedule an async task on the running event loop.
370
+
371
+ Raises RuntimeError if no event loop is running.
372
+ """
373
+ if self._event_loop is None or not self._event_loop.is_running():
374
+ raise RuntimeError("No running event loop -- call app.run() first")
375
+ task = asyncio.ensure_future(coro)
376
+ self._background_tasks.add(task)
377
+ task.add_done_callback(self._background_tasks.discard)
378
+ return task
379
+
380
+ async def _async_poll(self) -> None:
381
+ """Poll Tk events cooperatively with asyncio."""
382
+ if self._root is None:
383
+ return
384
+ try:
385
+ while self._root.tk.dooneevent(0):
386
+ pass
387
+ except tk.TclError:
388
+ return
389
+
390
+ def stop(self) -> None:
391
+ """Signal the async mainloop to stop gracefully."""
392
+ self._async_stop = True
393
+
394
+ async def _async_mainloop(self) -> None:
395
+ """Cooperative async mainloop: Tk event processing + asyncio scheduler.
396
+
397
+ Terminates when the window is closed (via WM_DELETE_WINDOW)
398
+ or application sets ``self._async_stop = True``.
399
+ """
400
+ root = self._root
401
+ if root is None:
402
+ return
403
+
404
+ self._async_stop = False
405
+
406
+ def _on_close():
407
+ self._async_stop = True
408
+ root.destroy()
409
+ root.protocol("WM_DELETE_WINDOW", _on_close)
410
+
411
+ try:
412
+ while not self._async_stop:
413
+ try:
414
+ root.update()
415
+ except tk.TclError:
416
+ break
417
+ await asyncio.sleep(0.01)
418
+ finally:
419
+ self._async_stop = True
420
+
421
+ def draw_canvas_items(self) -> None:
422
+ """Draw configured canvas items for all registered canvas widgets."""
423
+ for spec in self.widget_specs(kind="canvas"):
424
+ w = self.widget(spec.name)
425
+ if w is None:
426
+ continue
427
+ for item in spec.extras.get("items", []):
428
+ if not isinstance(item, tuple) or len(item) < 2:
429
+ continue
430
+ kind = item[0]
431
+ if not isinstance(kind, str):
432
+ continue
433
+ method = getattr(w, f"create_{kind}", None)
434
+ if method is None:
435
+ continue
436
+ *args, kwargs = item[1:]
437
+ if isinstance(kwargs, dict):
438
+ method(*args, **kwargs)
439
+ else:
440
+ method(*(item[1:]))
441
+
442
+ def pack_view_widgets(
443
+ self,
444
+ view_name: str,
445
+ *,
446
+ center_kinds: set[str] | None = None,
447
+ fill: FillLike = "x",
448
+ pady: int = 2,
449
+ ) -> None:
450
+ """Pack all widgets registered in a named view."""
451
+ centered = center_kinds or set()
452
+ for wname in self.view_widget_names(view_name):
453
+ w = self.widget(wname)
454
+ if w is None:
455
+ continue
456
+ kind = self.widget_kind(wname)
457
+ if kind in centered:
458
+ w.pack(pady=pady)
459
+ else:
460
+ w.pack(fill=fill, pady=pady)
461
+
462
+ # ── widget registration decorators ──
463
+
464
+ def label(
465
+ self,
466
+ name: str,
467
+ *,
468
+ role: str | None = None,
469
+ description: str | None = None,
470
+ font: tuple[str, int] | tuple[str, int, str] | None = None,
471
+ anchor: str | None = None,
472
+ justify: str | None = None,
473
+ padding: int | tuple[int, int] | None = None,
474
+ ) -> Callable[[LabelCallback], LabelCallback]:
475
+ """Register a label. Decorated function returns text or state dict."""
476
+ def decorator(fn: LabelCallback) -> LabelCallback:
477
+ extras: dict[str, Any] = {}
478
+ if font is not None:
479
+ extras["font"] = font
480
+ if anchor is not None:
481
+ extras["anchor"] = anchor
482
+ if justify is not None:
483
+ extras["justify"] = justify
484
+ if padding is not None:
485
+ extras["padding"] = padding
486
+ self._widgets.append(WidgetSpec(
487
+ name=name, kind="label", role=role, description=description,
488
+ on_update=fn, extras=extras,
489
+ ))
490
+ return fn
491
+ return decorator
492
+
493
+ def status(
494
+ self,
495
+ name: str,
496
+ *,
497
+ role: str | None = "status",
498
+ description: str | None = None,
499
+ ) -> Callable[[LabelCallback], LabelCallback]:
500
+ """Register a label with ``role=\"status\"``."""
501
+ return self.label(name, role=role, description=description)
502
+
503
+ def message(
504
+ self,
505
+ name: str,
506
+ *,
507
+ role: str | None = None,
508
+ description: str | None = None,
509
+ width: int | None = None,
510
+ auto_width: bool = True,
511
+ ) -> Callable[[LabelCallback], LabelCallback]:
512
+ """Register a message widget with wrap support.
513
+
514
+ ``width``: initial wrap width in pixels. If omitted and ``auto_width=True``,
515
+ width follows parent container resize.
516
+ """
517
+ def decorator(fn: LabelCallback) -> LabelCallback:
518
+ extras: dict[str, Any] = {"auto_width": auto_width}
519
+ if width is not None:
520
+ extras["width"] = width
521
+ self._widgets.append(WidgetSpec(
522
+ name=name, kind="message", role=role, description=description,
523
+ on_update=fn, extras=extras,
524
+ ))
525
+ return fn
526
+ return decorator
527
+
528
+ def button(
529
+ self,
530
+ name: str,
531
+ *,
532
+ label: str = "",
533
+ role: str | None = "button",
534
+ description: str | None = None,
535
+ state: StateLike = "normal",
536
+ enabled_if: Callable[[dict[str, Any]], bool] | None = None,
537
+ ) -> Callable[[ButtonCallback], ButtonCallback]:
538
+ """Register a button. Callback receives entry values dict → returns state dict."""
539
+ def decorator(fn: ButtonCallback) -> ButtonCallback:
540
+ self._widgets.append(WidgetSpec(
541
+ name=name, kind="button", label_text=label, role=role,
542
+ description=description, on_click=fn, enabled_if=enabled_if,
543
+ ))
544
+ return fn
545
+ return decorator
546
+
547
+ def entry(
548
+ self,
549
+ name: str,
550
+ *,
551
+ placeholder: str = "",
552
+ placeholder_as_hint: bool = True,
553
+ role: str | None = None,
554
+ description: str | None = None,
555
+ state: StateLike = "normal",
556
+ show: str | None = None,
557
+ width: int | None = None,
558
+ ) -> Callable[[ValueCallback], ValueCallback]:
559
+ """Register an entry. Callback receives value string → returns state dict.
560
+
561
+ ``show``: set ``\"*\"`` for password entry.
562
+ ``width``: character width.
563
+ """
564
+ def decorator(fn: ValueCallback) -> ValueCallback:
565
+ extras = {}
566
+ if show is not None:
567
+ extras["show"] = show
568
+ if width is not None:
569
+ extras["width"] = width
570
+ self._widgets.append(WidgetSpec(
571
+ name=name, kind="entry", placeholder=placeholder,
572
+ placeholder_as_hint=placeholder_as_hint,
573
+ role=role, description=description, on_update=fn,
574
+ extras=extras,
575
+ ))
576
+ return fn
577
+ return decorator
578
+
579
+ def checkbutton(
580
+ self,
581
+ name: str,
582
+ *,
583
+ text: str = "",
584
+ key: str | None = None,
585
+ description: str | None = None,
586
+ ) -> Callable[[BoolCallback], BoolCallback]:
587
+ """Register a checkbutton. Callback receives bool → returns state dict.
588
+
589
+ ``state[key]`` is ``\"1\"`` or ``\"0\"``. Key defaults to name.
590
+ """
591
+ actual_key = key or name
592
+ def decorator(fn: BoolCallback) -> BoolCallback:
593
+ self._widgets.append(WidgetSpec(
594
+ name=name, kind="checkbutton", label_text=text,
595
+ description=description, on_update=fn,
596
+ extras={"state_key": actual_key},
597
+ ))
598
+ return fn
599
+ return decorator
600
+
601
+ def radiobutton(
602
+ self,
603
+ name: str,
604
+ *,
605
+ text: str = "",
606
+ value: str = "",
607
+ group: str = "radio",
608
+ description: str | None = None,
609
+ ) -> Callable[[ValueCallback], ValueCallback]:
610
+ """Register a radiobutton. Callback receives selected value → returns state dict.
611
+
612
+ All radiobuttons sharing the same *group* write to ``state[group]``.
613
+ """
614
+ def decorator(fn: ValueCallback) -> ValueCallback:
615
+ self._widgets.append(WidgetSpec(
616
+ name=name, kind="radiobutton", label_text=text,
617
+ description=description, on_update=fn,
618
+ extras={"rb_value": value, "group_key": group},
619
+ ))
620
+ return fn
621
+ return decorator
622
+
623
+ def text(
624
+ self,
625
+ name: str,
626
+ *,
627
+ width: int = 50,
628
+ height: int = 8,
629
+ description: str | None = None,
630
+ state: StateLike = "normal",
631
+ ) -> Callable[[ValueCallback], ValueCallback]:
632
+ """Register a multiline text widget. Callback receives full content → returns state dict."""
633
+ def decorator(fn: ValueCallback) -> ValueCallback:
634
+ self._widgets.append(WidgetSpec(
635
+ name=name, kind="text", description=description,
636
+ on_update=fn,
637
+ extras={"width": width, "height": height},
638
+ ))
639
+ return fn
640
+ return decorator
641
+
642
+ def scale(
643
+ self,
644
+ name: str,
645
+ *,
646
+ key: str | None = None,
647
+ from_: int = 0,
648
+ to: int = 100,
649
+ orient: OrientLike = "horizontal",
650
+ description: str | None = None,
651
+ ) -> Callable[[ValueCallback], ValueCallback]:
652
+ """Register a scale slider. Callback receives current value → returns state dict.
653
+
654
+ ``state[key]`` holds the int value. Key defaults to name.
655
+ """
656
+ actual_key = key or name
657
+ def decorator(fn: ValueCallback) -> ValueCallback:
658
+ self._widgets.append(WidgetSpec(
659
+ name=name, kind="scale", description=description,
660
+ on_update=fn,
661
+ extras={"state_key": actual_key, "from": from_,
662
+ "to": to, "orient": orient},
663
+ ))
664
+ return fn
665
+ return decorator
666
+
667
+ def spinbox(
668
+ self,
669
+ name: str,
670
+ *,
671
+ key: str | None = None,
672
+ from_: float | None = None,
673
+ to: float | None = None,
674
+ values: list[str] | None = None,
675
+ description: str | None = None,
676
+ ) -> Callable[[ValueCallback], ValueCallback]:
677
+ """Register a spinbox. Callback receives current value → returns state dict.
678
+
679
+ ``state[key]`` holds the string value. Key defaults to name.
680
+ """
681
+ actual_key = key or name
682
+ def decorator(fn: ValueCallback) -> ValueCallback:
683
+ self._widgets.append(WidgetSpec(
684
+ name=name, kind="spinbox", description=description,
685
+ on_update=fn,
686
+ extras={"state_key": actual_key, "from": from_,
687
+ "to": to, "values": values},
688
+ ))
689
+ return fn
690
+ return decorator
691
+
692
+ def listbox(
693
+ self,
694
+ name: str,
695
+ *,
696
+ items: list[str] | None = None,
697
+ selectmode: SelectModeLike = "browse",
698
+ height: int | None = None,
699
+ description: str | None = None,
700
+ enabled_if: Callable[[dict[str, Any]], bool] | None = None,
701
+ ) -> Callable[[ValueCallback], ValueCallback]:
702
+ """Register a listbox. Callback receives selected item → returns state dict.
703
+
704
+ ``state[name]`` holds the selected item string.
705
+ ``enabled_if``: disables selection when False (sets selectmode="none").
706
+ """
707
+ def decorator(fn: ValueCallback) -> ValueCallback:
708
+ extras: dict[str, Any] = {"items": items or [], "selectmode": selectmode}
709
+ if height is not None:
710
+ extras["height"] = height
711
+ self._widgets.append(WidgetSpec(
712
+ name=name, kind="listbox", description=description,
713
+ on_update=fn, extras=extras, enabled_if=enabled_if,
714
+ ))
715
+ return fn
716
+ return decorator
717
+
718
+ def canvas(
719
+ self,
720
+ name: str,
721
+ *,
722
+ width: int = 300,
723
+ height: int = 200,
724
+ bg: str = "#f0f0f0",
725
+ description: str | None = None,
726
+ items: list | None = None,
727
+ ) -> Callable[[Callable[[], None]], Callable[[], None]]:
728
+ """Register a canvas (display only).
729
+
730
+ ``items``: list of ``(kind, *args, kwargs)`` to draw via ``create_{kind}``.
731
+ """
732
+ def decorator(fn: Callable[[], None] | None = None) -> Callable[[], None]:
733
+ extras: dict[str, Any] = {"width": width, "height": height, "bg": bg}
734
+ if items:
735
+ extras["items"] = items
736
+ self._widgets.append(WidgetSpec(
737
+ name=name, kind="canvas", description=description,
738
+ extras=extras,
739
+ ))
740
+ return fn # type: ignore[return-value]
741
+ return decorator
742
+
743
+ # ── state management ──
744
+
745
+ def _apply_state(self, update: dict[str, Any] | str) -> None:
746
+ if isinstance(update, str):
747
+ self._state["_last"] = update
748
+ elif isinstance(update, dict):
749
+ self._state.update(update)
750
+ for key, val in update.items():
751
+ var = self._tk_vars.get(key)
752
+ if var is None:
753
+ continue
754
+ s = "" if val is None else str(val)
755
+ var.set(s)
756
+ w = self._tk_widgets.get(key)
757
+ if isinstance(w, (tk.Entry, ttk.Entry)):
758
+ self._apply_entry_value_after_state(key, w, var, s)
759
+ self._sync_widgets()
760
+ self._sync_widget_states()
761
+
762
+ def _entry_values_dict(self) -> dict[str, Any]:
763
+ values: dict[str, Any] = {}
764
+ for ws in self._widgets:
765
+ if ws.kind == "entry":
766
+ values[ws.name] = self._entry_effective_value(ws.name)
767
+ return values
768
+
769
+ def _sync_widget_states(self) -> None:
770
+ if not self._tk_widgets:
771
+ return
772
+ values = self._entry_values_dict()
773
+ for spec in self._widgets:
774
+ if spec.enabled_if is None:
775
+ continue
776
+ tk_w = self._tk_widgets.get(spec.name)
777
+ if tk_w is None:
778
+ continue
779
+ try:
780
+ ok = bool(spec.enabled_if(values))
781
+ except Exception:
782
+ ok = True
783
+ if spec.kind == "button" and isinstance(tk_w, (tk.Button, ttk.Button)):
784
+ tk_w.configure(state="normal" if ok else "disabled")
785
+ elif spec.kind == "listbox" and isinstance(tk_w, tk.Listbox):
786
+ if ok:
787
+ orig_sm = str(spec.extras.get("selectmode", "browse"))
788
+ tk_w.configure(state="normal", selectmode=orig_sm)
789
+ else:
790
+ tk_w.selection_clear(0, "end")
791
+ tk_w.configure(state="disabled")
792
+
793
+ def _entry_spec(self, name: str) -> WidgetSpec | None:
794
+ for w in self._widgets:
795
+ if w.name == name and w.kind == "entry":
796
+ return w
797
+ return None
798
+
799
+ def _entry_effective_value(self, name: str) -> str:
800
+ w = self._tk_widgets.get(name)
801
+ var = self._tk_vars.get(name)
802
+ if var is None or not isinstance(w, (tk.Entry, ttk.Entry)):
803
+ return ""
804
+ spec = self._entry_spec(name)
805
+ if spec is not None and spec.placeholder_as_hint:
806
+ if getattr(w, "_nextpytk_ph_active", False):
807
+ return ""
808
+ ph = getattr(w, "_nextpytk_placeholder", "") or ""
809
+ if ph and var.get() == ph:
810
+ return ""
811
+ return var.get()
812
+
813
+ def _entry_focus_in(self, name: str) -> None:
814
+ try:
815
+ w = self._tk_widgets.get(name)
816
+ var = self._tk_vars.get(name)
817
+ if var is None or not isinstance(w, (tk.Entry, ttk.Entry)):
818
+ return
819
+ spec = self._entry_spec(name)
820
+ if spec is None or not spec.placeholder_as_hint:
821
+ return
822
+ ph = getattr(w, "_nextpytk_placeholder", "") or ""
823
+ if not ph:
824
+ return
825
+ if getattr(w, "_nextpytk_ph_active", False) or var.get() == ph:
826
+ var.set("")
827
+ setattr(w, "_nextpytk_ph_active", False)
828
+ try:
829
+ w.configure(foreground=getattr(w, "_nextpytk_fg_normal",
830
+ w.cget("foreground")))
831
+ except Exception:
832
+ pass
833
+ finally:
834
+ self._sync_widget_states()
835
+
836
+ def _entry_focus_out(self, name: str) -> None:
837
+ try:
838
+ w = self._tk_widgets.get(name)
839
+ var = self._tk_vars.get(name)
840
+ if var is None or not isinstance(w, (tk.Entry, ttk.Entry)):
841
+ return
842
+ spec = self._entry_spec(name)
843
+ if spec is None or not spec.placeholder_as_hint:
844
+ return
845
+ ph = getattr(w, "_nextpytk_placeholder", "") or ""
846
+ if not ph:
847
+ return
848
+ if not var.get().strip():
849
+ var.set(ph)
850
+ setattr(w, "_nextpytk_ph_active", True)
851
+ try:
852
+ w.configure(foreground="grey")
853
+ except Exception:
854
+ pass
855
+ finally:
856
+ self._sync_widget_states()
857
+
858
+ def _apply_entry_value_after_state(self, name: str, w: tk.Entry | ttk.Entry,
859
+ var: tk.Variable, s: str) -> None:
860
+ spec = self._entry_spec(name)
861
+ if spec is None or not spec.placeholder_as_hint:
862
+ setattr(w, "_nextpytk_ph_active", False)
863
+ try:
864
+ w.configure(foreground=getattr(w, "_nextpytk_fg_normal",
865
+ w.cget("foreground")))
866
+ except Exception:
867
+ pass
868
+ return
869
+ ph = getattr(w, "_nextpytk_placeholder", "") or ""
870
+ try:
871
+ fg0 = getattr(w, "_nextpytk_fg_normal", w.cget("foreground"))
872
+ except Exception:
873
+ fg0 = getattr(w, "_nextpytk_fg_normal", None)
874
+ if not ph:
875
+ setattr(w, "_nextpytk_ph_active", False)
876
+ try:
877
+ if fg0 is not None:
878
+ w.configure(foreground=fg0)
879
+ except Exception:
880
+ pass
881
+ return
882
+ if s.strip():
883
+ setattr(w, "_nextpytk_ph_active", False)
884
+ try:
885
+ if fg0 is not None:
886
+ w.configure(foreground=fg0)
887
+ except Exception:
888
+ pass
889
+ return
890
+ setattr(w, "_nextpytk_ph_active", False)
891
+ try:
892
+ if fg0 is not None:
893
+ w.configure(foreground=fg0)
894
+ except Exception:
895
+ pass
896
+ focus = w.focus_get()
897
+ if focus is not None and str(focus) == str(w):
898
+ return
899
+ var.set(ph)
900
+ setattr(w, "_nextpytk_ph_active", True)
901
+ try:
902
+ w.configure(foreground="grey")
903
+ except Exception:
904
+ pass
905
+
906
+ def _bind_message_auto_width(self, w: tk.Message, master: tk.Misc,
907
+ *, padding: int = 16, min_width: int = 120) -> None:
908
+ def _update_width(evt: tk.Event[tk.Misc] | None = None) -> None:
909
+ width = master.winfo_width()
910
+ if evt is not None:
911
+ width = evt.width
912
+ target = max(min_width, width - padding)
913
+ try:
914
+ w.configure(width=target)
915
+ except Exception:
916
+ pass
917
+ master.bind("<Configure>", _update_width, add="+")
918
+ w.after_idle(_update_width)
919
+
920
+ def _sync_widgets(self) -> None:
921
+ """Push state to label widgets."""
922
+ for spec in self._widgets:
923
+ if spec.kind not in ("label", "status", "message"):
924
+ continue
925
+ tk_w = self._tk_widgets.get(spec.name)
926
+ if tk_w is None:
927
+ continue
928
+ value = self._state.get(spec.name, "")
929
+ if spec.name not in self._state and spec.on_update is not None:
930
+ try:
931
+ result = spec.on_update()
932
+ if isinstance(result, str):
933
+ value = result
934
+ elif isinstance(result, dict):
935
+ value = result.get(spec.name, value)
936
+ except Exception:
937
+ pass
938
+ tk_w.configure(text=str(value)) # type: ignore[call-arg]
939
+
940
+ # ── build & run ──
941
+
942
+ def _build_widgets(self) -> None:
943
+ """Create tkinter widgets from registered specs."""
944
+ if self._root is None:
945
+ return
946
+
947
+ for spec in self._widgets:
948
+ master = self._widget_masters.get(spec.name, self._root)
949
+ kind = spec.kind
950
+ e = spec.extras
951
+
952
+ if kind == "label":
953
+ w = ttk.Label(master, text="", anchor="center", justify="center")
954
+ for opt in ("font", "anchor", "justify", "padding"):
955
+ if opt in e:
956
+ w.configure(**{opt: e[opt]})
957
+ self._tk_widgets[spec.name] = w
958
+ if spec.on_update is not None:
959
+ try:
960
+ result = spec.on_update()
961
+ if isinstance(result, str):
962
+ w.configure(text=result)
963
+ elif isinstance(result, dict):
964
+ w.configure(text=str(list(result.values())[0]) if result else "")
965
+ except Exception:
966
+ w.configure(text="")
967
+
968
+ elif kind == "button":
969
+ w = ttk.Button(master, text=spec.label_text or spec.name)
970
+ self._tk_widgets[spec.name] = w
971
+ if spec.on_click is not None:
972
+ fn = spec.on_click
973
+ w.configure(command=lambda s=spec, f=fn: self._on_button_click(s, f))
974
+
975
+ elif kind == "entry":
976
+ var = tk.StringVar(value="")
977
+ w = ttk.Entry(master, textvariable=var)
978
+ self._tk_widgets[spec.name] = w
979
+ self._tk_vars[spec.name] = var
980
+ # Apply widget options from extras
981
+ for opt in ("show", "width"):
982
+ if opt in e:
983
+ w.configure(**{opt: e[opt]})
984
+ if spec.placeholder_as_hint and spec.placeholder:
985
+ ph = spec.placeholder
986
+ var.set(ph)
987
+ setattr(w, "_nextpytk_ph_active", True)
988
+ setattr(w, "_nextpytk_placeholder", ph)
989
+ try:
990
+ setattr(w, "_nextpytk_fg_normal", w.cget("foreground"))
991
+ w.configure(foreground="grey")
992
+ except Exception:
993
+ setattr(w, "_nextpytk_fg_normal", None)
994
+ w.bind("<FocusIn>", lambda _e, n=spec.name: self._entry_focus_in(n))
995
+ w.bind("<FocusOut>", lambda _e, n=spec.name: self._entry_focus_out(n))
996
+ if spec.on_update is not None:
997
+ fn = spec.on_update
998
+ w.bind("<KeyRelease>", lambda _e, s=spec, f=fn: self._on_entry_change(s, f))
999
+
1000
+ elif kind == "checkbutton":
1001
+ key = e.get("state_key", spec.name)
1002
+ var = tk.StringVar(value="0")
1003
+ w = ttk.Checkbutton(master, text=spec.label_text, variable=var,
1004
+ onvalue="1", offvalue="0")
1005
+ self._tk_widgets[spec.name] = w
1006
+ self._tk_vars[key] = var
1007
+ if spec.on_update is not None:
1008
+ fn = spec.on_update
1009
+ w.configure(command=lambda s=spec, f=fn, v=var, k=key:
1010
+ self._on_checkbutton_change(s, f, v, k))
1011
+
1012
+ elif kind == "radiobutton":
1013
+ gk = e.get("group_key", "radio")
1014
+ val = e.get("rb_value", "")
1015
+ if gk not in self._tk_vars:
1016
+ self._tk_vars[gk] = tk.StringVar(value="")
1017
+ var = self._tk_vars[gk]
1018
+ w = ttk.Radiobutton(master, text=spec.label_text, variable=var,
1019
+ value=val)
1020
+ self._tk_widgets[spec.name] = w
1021
+ if spec.on_update is not None:
1022
+ fn = spec.on_update
1023
+ w.configure(command=lambda s=spec, f=fn, v=var, k=gk:
1024
+ self._on_radiobutton_change(s, f, v, k))
1025
+
1026
+ elif kind == "text":
1027
+ w = tk.Text(master, width=e.get("width", 50), height=e.get("height", 8),
1028
+ name=spec.name)
1029
+ self._tk_widgets[spec.name] = w
1030
+ if spec.on_update is not None:
1031
+ fn = spec.on_update
1032
+ w.bind("<KeyRelease>", lambda _e, s=spec, f=fn:
1033
+ self._on_text_change(s, f))
1034
+
1035
+ elif kind == "scale":
1036
+ key = e.get("state_key", spec.name)
1037
+ var = tk.IntVar(value=int(e.get("from", 0)))
1038
+ orient_str: OrientLike = e.get("orient", "horizontal")
1039
+ w = ttk.Scale(master, from_=e.get("from", 0), to=e.get("to", 100),
1040
+ orient=orient_str, variable=var) # type: ignore[arg-type]
1041
+ self._tk_widgets[spec.name] = w
1042
+ self._tk_vars[key] = var
1043
+ self._state[key] = str(var.get())
1044
+ if spec.on_update is not None:
1045
+ fn = spec.on_update
1046
+ var.trace_add("write", lambda *_a, s=spec, f=fn, v=var, k=key:
1047
+ self._on_var_change(s, f, v, k))
1048
+
1049
+ elif kind == "spinbox":
1050
+ key = e.get("state_key", spec.name)
1051
+ init_val = ""
1052
+ if e.get("values"):
1053
+ vals = e.get("values", [])
1054
+ if isinstance(vals, list) and vals:
1055
+ init_val = str(vals[0])
1056
+ elif e.get("from") is not None:
1057
+ init_val = str(e.get("from"))
1058
+ var = tk.StringVar(value=init_val)
1059
+ kwargs: dict[str, Any] = {}
1060
+ if e.get("from") is not None:
1061
+ kwargs["from_"] = e["from"]
1062
+ if e.get("to") is not None:
1063
+ kwargs["to"] = e["to"]
1064
+ if e.get("values"):
1065
+ kwargs["values"] = e["values"]
1066
+ w = ttk.Spinbox(master, textvariable=var, **kwargs)
1067
+ self._tk_widgets[spec.name] = w
1068
+ self._tk_vars[key] = var
1069
+ if init_val:
1070
+ self._state[key] = init_val
1071
+ if spec.on_update is not None:
1072
+ fn = spec.on_update
1073
+ var.trace_add("write", lambda *_a, s=spec, f=fn, v=var, k=key:
1074
+ self._on_var_change(s, f, v, k))
1075
+
1076
+ elif kind == "listbox":
1077
+ kwargs_lb: dict[str, Any] = {}
1078
+ if e.get("height"):
1079
+ kwargs_lb["height"] = e["height"]
1080
+ if e.get("selectmode"):
1081
+ kwargs_lb["selectmode"] = e["selectmode"]
1082
+ w = tk.Listbox(master, name=spec.name, **kwargs_lb)
1083
+ for item in e.get("items", []):
1084
+ w.insert("end", item)
1085
+ self._tk_widgets[spec.name] = w
1086
+ if spec.on_update is not None:
1087
+ fn = spec.on_update
1088
+ w.bind("<<ListboxSelect>>", lambda _e, s=spec, f=fn:
1089
+ self._on_listbox_select(s, f))
1090
+
1091
+ elif kind == "canvas":
1092
+ w = tk.Canvas(master, width=e.get("width", 300), height=e.get("height", 200),
1093
+ bg=e.get("bg", "#f0f0f0"), name=spec.name)
1094
+ self._tk_widgets[spec.name] = w
1095
+
1096
+ elif kind == "message":
1097
+ w = tk.Message(master, text="", name=spec.name)
1098
+ self._tk_widgets[spec.name] = w
1099
+ if e.get("width") is not None:
1100
+ w.configure(width=e["width"])
1101
+ if e.get("auto_width", True):
1102
+ self._bind_message_auto_width(w, master)
1103
+ if spec.on_update is not None:
1104
+ try:
1105
+ result = spec.on_update()
1106
+ if isinstance(result, str):
1107
+ w.configure(text=result)
1108
+ elif isinstance(result, dict):
1109
+ w.configure(text=str(list(result.values())[0]) if result else "")
1110
+ except Exception:
1111
+ w.configure(text="")
1112
+
1113
+ # ── event handlers ──
1114
+
1115
+ def _on_button_click(self, spec: WidgetSpec, fn: Any) -> None:
1116
+ try:
1117
+ values = self._entry_values_dict()
1118
+ result = fn(values)
1119
+ self._apply_state(result)
1120
+ except Exception as e:
1121
+ self._apply_state({"error": str(e)})
1122
+
1123
+ def _on_entry_change(self, spec: WidgetSpec, fn: Any) -> None:
1124
+ try:
1125
+ value = self._entry_effective_value(spec.name)
1126
+ result = fn(value)
1127
+ self._apply_state(result)
1128
+ except Exception:
1129
+ pass
1130
+
1131
+ def _on_checkbutton_change(self, spec: WidgetSpec, fn: Any,
1132
+ var: tk.StringVar, key: str) -> None:
1133
+ val = var.get()
1134
+ self._state[key] = val
1135
+ try:
1136
+ result = fn(val == "1")
1137
+ self._apply_state(result)
1138
+ except Exception:
1139
+ pass
1140
+
1141
+ def _on_radiobutton_change(self, spec: WidgetSpec, fn: Any,
1142
+ var: tk.Variable, key: str) -> None:
1143
+ val = var.get()
1144
+ self._state[key] = val
1145
+ try:
1146
+ result = fn(val)
1147
+ self._apply_state(result)
1148
+ except Exception:
1149
+ pass
1150
+
1151
+ def _on_var_change(self, spec: WidgetSpec, fn: Any,
1152
+ var: tk.Variable, key: str) -> None:
1153
+ val = var.get()
1154
+ self._state[key] = val
1155
+ try:
1156
+ result = fn(val)
1157
+ self._apply_state(result)
1158
+ except Exception:
1159
+ pass
1160
+
1161
+ def _on_text_change(self, spec: WidgetSpec, fn: Any) -> None:
1162
+ w = self._tk_widgets.get(spec.name)
1163
+ value = ""
1164
+ if w is not None and hasattr(w, "get"):
1165
+ value = w.get("1.0", "end-1c") # type: ignore[attr-defined]
1166
+ try:
1167
+ result = fn(value)
1168
+ self._apply_state(result)
1169
+ except Exception:
1170
+ pass
1171
+
1172
+ def _on_listbox_select(self, spec: WidgetSpec, fn: Any) -> None:
1173
+ w = self._tk_widgets.get(spec.name)
1174
+ value = ""
1175
+ if w is not None and hasattr(w, "curselection"):
1176
+ sel = w.curselection() # type: ignore[attr-defined]
1177
+ if sel and hasattr(w, "get"):
1178
+ value = w.get(sel[0]) # type: ignore[attr-defined]
1179
+ self._state[spec.name] = value
1180
+ try:
1181
+ result = fn(value)
1182
+ self._apply_state(result)
1183
+ except Exception:
1184
+ pass
1185
+
1186
+ def layout(self) -> LayoutBuilder:
1187
+ """Return a LayoutBuilder for declarative ``with``-block layout construction.
1188
+
1189
+ Usage::
1190
+
1191
+ with app.layout() as b:
1192
+ b.section("title")
1193
+ with b.grid(col_weights=(0, 1)):
1194
+ b.widget("celsius", sticky="ew")
1195
+ b.widget("fahrenheit", sticky="ew")
1196
+ app.run(layout=b.build())
1197
+ """
1198
+ from nextpytk.layout import LayoutBuilder
1199
+ return LayoutBuilder()
1200
+
1201
+ def run(
1202
+ self,
1203
+ *,
1204
+ layout: Any = None,
1205
+ initial_state: dict[str, Any] | None = None,
1206
+ multiview: str | None = None,
1207
+ on_ready: Callable[[TkApp], None] | None = None,
1208
+ geometry: str | None = None,
1209
+ ) -> None:
1210
+ """Build and run the Tk application.
1211
+
1212
+ on_ready is called after widget building and state application,
1213
+ just before "mainloop()". Use it for dynamic widget population,
1214
+ key bindings, or other imperative setup that needs widgets to exist.
1215
+ geometry: initial window size, e.g. "640x480".
1216
+ """
1217
+ if multiview is not None:
1218
+ if layout is not None:
1219
+ raise ValueError("layout and multiview cannot be used together in run()")
1220
+ cfg = self._multiviews.get(multiview)
1221
+ if cfg is None:
1222
+ raise ValueError(f"Multiview '{multiview}' is not declared")
1223
+ declared_initial = cfg.get("initial_state") or {}
1224
+ merged_initial: dict[str, Any] | None
1225
+ if initial_state:
1226
+ merged_initial = {**declared_initial, **initial_state}
1227
+ else:
1228
+ merged_initial = declared_initial or None
1229
+ self.run_multiview(
1230
+ views=cfg["views"],
1231
+ toplevel_widgets=cfg["toplevel_widgets"],
1232
+ initial_state=merged_initial,
1233
+ view_layouts=cfg.get("view_layouts"),
1234
+ center_kinds=cfg.get("center_kinds"),
1235
+ on_tab_change=cfg.get("on_tab_change"),
1236
+ on_ready=on_ready,
1237
+ )
1238
+ return
1239
+
1240
+ self._root = tk.Tk()
1241
+ self._root.title(self._title)
1242
+ if geometry:
1243
+ self._root.geometry(geometry)
1244
+ self._tk_widgets.clear()
1245
+ self._tk_vars.clear()
1246
+ self._widget_masters.clear()
1247
+ self._row_pack_jobs.clear()
1248
+ self._grid_pack_jobs.clear()
1249
+
1250
+ if layout is not None:
1251
+ if isinstance(layout, list):
1252
+ from nextpytk.layout import Layout
1253
+ layout = Layout.from_list(layout)
1254
+ layout.mount_frames(self)
1255
+
1256
+ self._build_widgets()
1257
+
1258
+ if layout is not None:
1259
+ layout.pack_children(self)
1260
+
1261
+ if initial_state:
1262
+ self.apply_state(initial_state)
1263
+
1264
+ if on_ready is not None:
1265
+ on_ready(self)
1266
+
1267
+ self._sync_widgets()
1268
+ self._sync_widget_states()
1269
+ self._root.mainloop()
1270
+
1271
+ def run_async(
1272
+ self,
1273
+ *,
1274
+ layout: Any = None,
1275
+ initial_state: dict[str, Any] | None = None,
1276
+ multiview: str | None = None,
1277
+ on_ready: Callable[[TkApp], None] | None = None,
1278
+ geometry: str | None = None,
1279
+ ) -> None:
1280
+ """Build and run the Tk application with asyncio event loop.
1281
+
1282
+ Use ``app.spawn(coro)`` inside ``on_ready`` to schedule async tasks.
1283
+ geometry: initial window size, e.g. "640x480".
1284
+ """
1285
+ if multiview is not None:
1286
+ if layout is not None:
1287
+ raise ValueError(
1288
+ "layout and multiview cannot be used together in run_async()"
1289
+ )
1290
+ cfg = self._multiviews.get(multiview)
1291
+ if cfg is None:
1292
+ raise ValueError(f"Multiview '{multiview}' is not declared")
1293
+ declared_initial = cfg.get("initial_state") or {}
1294
+ merged_initial: dict[str, Any] | None
1295
+ if initial_state:
1296
+ merged_initial = {**declared_initial, **initial_state}
1297
+ else:
1298
+ merged_initial = declared_initial or None
1299
+ self.run_multiview(
1300
+ views=cfg["views"],
1301
+ toplevel_widgets=cfg["toplevel_widgets"],
1302
+ initial_state=merged_initial,
1303
+ view_layouts=cfg.get("view_layouts"),
1304
+ center_kinds=cfg.get("center_kinds"),
1305
+ on_tab_change=cfg.get("on_tab_change"),
1306
+ on_ready=on_ready,
1307
+ )
1308
+ return
1309
+
1310
+ asyncio.run(self._async_run(layout, initial_state, on_ready, geometry))
1311
+
1312
+ async def _async_run(
1313
+ self,
1314
+ layout: Any | None,
1315
+ initial_state: dict[str, Any] | None,
1316
+ on_ready: Callable[[TkApp], None] | None,
1317
+ geometry: str | None = None,
1318
+ ) -> None:
1319
+ """Internal: build widgets and enter async mainloop."""
1320
+ self._root = tk.Tk()
1321
+ self._root.title(self._title)
1322
+ if geometry:
1323
+ self._root.geometry(geometry)
1324
+ self._tk_widgets.clear()
1325
+ self._tk_vars.clear()
1326
+ self._widget_masters.clear()
1327
+ self._row_pack_jobs.clear()
1328
+ self._grid_pack_jobs.clear()
1329
+ self._event_loop = asyncio.get_running_loop()
1330
+
1331
+ if layout is not None:
1332
+ layout.mount_frames(self)
1333
+
1334
+ self._build_widgets()
1335
+
1336
+ if layout is not None:
1337
+ layout.pack_children(self)
1338
+
1339
+ if initial_state:
1340
+ self.apply_state(initial_state)
1341
+
1342
+ if on_ready is not None:
1343
+ on_ready(self)
1344
+
1345
+ self._sync_widgets()
1346
+ self._sync_widget_states()
1347
+ await self._async_mainloop()
1348
+
1349
+ # ── schema export (for AI agents / LLM Function Calling) ──
1350
+
1351
+ def schema(self) -> dict[str, Any]:
1352
+ """Export widget registry as JSON-compatible schema."""
1353
+ widgets_out: list[dict[str, Any]] = []
1354
+ for w in self._widgets:
1355
+ d: dict[str, Any] = {
1356
+ "name": w.name,
1357
+ "kind": w.kind,
1358
+ "label": w.label_text,
1359
+ "role": w.role,
1360
+ "description": w.description,
1361
+ }
1362
+ if w.kind in ("checkbutton", "scale", "spinbox"):
1363
+ d["state_key"] = w.extras.get("state_key")
1364
+ if w.kind == "radiobutton":
1365
+ d["group_key"] = w.extras.get("group_key")
1366
+ d["rb_value"] = w.extras.get("rb_value")
1367
+ if w.kind == "listbox":
1368
+ d["items_count"] = len(w.extras.get("items", []))
1369
+ if w.kind == "entry":
1370
+ d["placeholder_as_hint"] = w.placeholder_as_hint
1371
+ widgets_out.append(d)
1372
+ return {"title": self._title, "widgets": widgets_out}
1373
+
1374
+ @property
1375
+ def state(self) -> dict[str, Any]:
1376
+ return dict(self._state)