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/__init__.py +8 -0
- nextpytk/app.py +1376 -0
- nextpytk/layout.py +512 -0
- nextpytk/types.py +160 -0
- nextpytk/widgets.py +45 -0
- nextpytk-0.2.0.dist-info/METADATA +301 -0
- nextpytk-0.2.0.dist-info/RECORD +9 -0
- nextpytk-0.2.0.dist-info/WHEEL +4 -0
- nextpytk-0.2.0.dist-info/licenses/LICENSE +21 -0
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)
|