pythonnative 0.18.0__py3-none-any.whl → 0.19.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.
- pythonnative/__init__.py +1 -1
- pythonnative/cli/pn.py +107 -1
- pythonnative/native_views/__init__.py +18 -5
- pythonnative/native_views/desktop.py +1489 -0
- pythonnative/platform.py +17 -8
- pythonnative/preview.py +471 -0
- pythonnative/runtime.py +26 -1
- pythonnative/screen.py +184 -4
- pythonnative/utils.py +38 -2
- {pythonnative-0.18.0.dist-info → pythonnative-0.19.0.dist-info}/METADATA +2 -1
- {pythonnative-0.18.0.dist-info → pythonnative-0.19.0.dist-info}/RECORD +15 -13
- {pythonnative-0.18.0.dist-info → pythonnative-0.19.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.18.0.dist-info → pythonnative-0.19.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.18.0.dist-info → pythonnative-0.19.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.18.0.dist-info → pythonnative-0.19.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1489 @@
|
|
|
1
|
+
"""Desktop native-view handlers (Tkinter).
|
|
2
|
+
|
|
3
|
+
The desktop backend renders a PythonNative app in a real OS window so
|
|
4
|
+
the inner development loop doesn't require a device build. It is driven
|
|
5
|
+
by ``pn preview`` (which sets ``PN_PLATFORM=desktop``) and powers the
|
|
6
|
+
in-process Fast Refresh loop in ``pythonnative.preview``.
|
|
7
|
+
|
|
8
|
+
Like the iOS and Android backends, **layout is owned by the pure-Python
|
|
9
|
+
flex engine** in [`pythonnative.layout`][pythonnative.layout]: the
|
|
10
|
+
reconciler computes each view's ``(x, y, width, height)`` in points and
|
|
11
|
+
[`set_frame`][pythonnative.native_views.desktop.DesktopViewHandler.set_frame]
|
|
12
|
+
applies it. Handlers therefore only deal with *visual* props (text,
|
|
13
|
+
colors, fonts, callbacks) and ignore everything in
|
|
14
|
+
[`LAYOUT_STYLE_KEYS`][pythonnative.layout.LAYOUT_STYLE_KEYS].
|
|
15
|
+
|
|
16
|
+
Placement strategy
|
|
17
|
+
------------------
|
|
18
|
+
Tkinter fixes a widget's master at construction time, but the
|
|
19
|
+
reconciler creates a view *before* it knows the parent (``create`` then
|
|
20
|
+
``add_child``). To bridge that, every widget is created under a single
|
|
21
|
+
shared *stage* frame (see
|
|
22
|
+
[`set_root_container`][pythonnative.native_views.desktop.set_root_container])
|
|
23
|
+
and positioned with ``place(in_=parent, ...)``. Tk's ``-in`` option
|
|
24
|
+
composes coordinates through nested parents, so the engine's
|
|
25
|
+
parent-relative frames render correctly without reparenting.
|
|
26
|
+
|
|
27
|
+
Scope
|
|
28
|
+
-----
|
|
29
|
+
This is a **preview** backend, not a production desktop target. It
|
|
30
|
+
favors fidelity of layout and behavior over pixel-perfect chrome:
|
|
31
|
+
rounded corners, shadows, per-widget opacity, and overflow clipping are
|
|
32
|
+
approximated or omitted (Tkinter can't express them cheaply). Every one
|
|
33
|
+
of the 25 built-in element types is handled so any app renders without
|
|
34
|
+
errors.
|
|
35
|
+
|
|
36
|
+
This module imports ``tkinter`` at import time, so it is only imported
|
|
37
|
+
when ``PN_PLATFORM=desktop``. Off-device unit tests inject a mock
|
|
38
|
+
registry via [`set_registry`][pythonnative.native_views.set_registry]
|
|
39
|
+
and never trigger this path.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import math
|
|
45
|
+
import re
|
|
46
|
+
import tkinter as tk
|
|
47
|
+
from tkinter import font as tkfont
|
|
48
|
+
from tkinter import ttk
|
|
49
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
50
|
+
|
|
51
|
+
from .base import ViewHandler
|
|
52
|
+
|
|
53
|
+
# ======================================================================
|
|
54
|
+
# Stage / root container
|
|
55
|
+
# ======================================================================
|
|
56
|
+
#
|
|
57
|
+
# Every Tk widget the backend creates is a child of this single frame.
|
|
58
|
+
# ``pn preview`` installs it before mounting the app; the placement
|
|
59
|
+
# logic (``_place``) positions widgets *inside* their logical parent via
|
|
60
|
+
# Tk's ``-in`` option, which only works when both windows share a
|
|
61
|
+
# top-level — guaranteed by the single-stage design.
|
|
62
|
+
|
|
63
|
+
_ROOT_CONTAINER: Any = None
|
|
64
|
+
_DEFAULT_FONT_SIZE = 15
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def set_root_container(container: Any) -> None:
|
|
68
|
+
"""Install the stage frame that every desktop view is created under.
|
|
69
|
+
|
|
70
|
+
Called by ``pythonnative.preview`` before the
|
|
71
|
+
first screen is mounted. ``container`` must be a Tk widget (a
|
|
72
|
+
``Frame`` filling the preview window).
|
|
73
|
+
"""
|
|
74
|
+
global _ROOT_CONTAINER
|
|
75
|
+
_ROOT_CONTAINER = container
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_root_container() -> Any:
|
|
79
|
+
"""Return the installed stage frame, or ``None`` if unset."""
|
|
80
|
+
return _ROOT_CONTAINER
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def clear_root_container() -> None:
|
|
84
|
+
"""Forget the stage frame (used when the preview window closes)."""
|
|
85
|
+
global _ROOT_CONTAINER
|
|
86
|
+
_ROOT_CONTAINER = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _master() -> Any:
|
|
90
|
+
"""Return the master widget new views should be constructed under."""
|
|
91
|
+
if _ROOT_CONTAINER is not None:
|
|
92
|
+
return _ROOT_CONTAINER
|
|
93
|
+
# Fall back to Tk's default root so the handlers stay usable in a
|
|
94
|
+
# bare REPL / test that created a Tk root but no explicit stage.
|
|
95
|
+
return tk._get_default_root()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ======================================================================
|
|
99
|
+
# Color + font helpers
|
|
100
|
+
# ======================================================================
|
|
101
|
+
|
|
102
|
+
_NAMED_PASSTHROUGH = re.compile(r"^[A-Za-z][A-Za-z0-9 ]*$")
|
|
103
|
+
_BOLD_WORDS = frozenset({"bold", "semibold", "black", "heavy", "extrabold", "extra_bold", "semi_bold"})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _tk_color(value: Any) -> Optional[str]:
|
|
107
|
+
"""Convert a PythonNative color into a Tk color string.
|
|
108
|
+
|
|
109
|
+
Accepts ``#rgb`` / ``#rrggbb`` / ``#aarrggbb`` hex (alpha is
|
|
110
|
+
dropped — Tk has no per-color alpha), ``rgb()`` / ``rgba()``
|
|
111
|
+
functional notation, ``(r, g, b)`` tuples, packed integers, and
|
|
112
|
+
named colors (passed through for Tk to resolve). Returns ``None``
|
|
113
|
+
for ``transparent`` / unparseable values so callers can leave the
|
|
114
|
+
widget's default background untouched.
|
|
115
|
+
"""
|
|
116
|
+
if value is None or isinstance(value, bool):
|
|
117
|
+
return None
|
|
118
|
+
if isinstance(value, int):
|
|
119
|
+
return "#%06x" % (value & 0xFFFFFF)
|
|
120
|
+
if isinstance(value, (tuple, list)) and len(value) >= 3:
|
|
121
|
+
try:
|
|
122
|
+
r, g, b = (int(value[0]) & 255, int(value[1]) & 255, int(value[2]) & 255)
|
|
123
|
+
return "#%02x%02x%02x" % (r, g, b)
|
|
124
|
+
except (TypeError, ValueError):
|
|
125
|
+
return None
|
|
126
|
+
s = str(value).strip()
|
|
127
|
+
if not s:
|
|
128
|
+
return None
|
|
129
|
+
low = s.lower()
|
|
130
|
+
if low in ("transparent", "clear", "none"):
|
|
131
|
+
return None
|
|
132
|
+
if s.startswith("#"):
|
|
133
|
+
hexd = s[1:]
|
|
134
|
+
if len(hexd) == 3:
|
|
135
|
+
return "#" + "".join(c * 2 for c in hexd)
|
|
136
|
+
if len(hexd) == 4: # #rgba -> drop alpha
|
|
137
|
+
return "#" + "".join(c * 2 for c in hexd[:3])
|
|
138
|
+
if len(hexd) == 6:
|
|
139
|
+
return "#" + hexd
|
|
140
|
+
if len(hexd) == 8: # #aarrggbb -> drop leading alpha
|
|
141
|
+
return "#" + hexd[2:]
|
|
142
|
+
return None
|
|
143
|
+
if low.startswith("rgb"):
|
|
144
|
+
nums = re.findall(r"[\d.]+", s)
|
|
145
|
+
if len(nums) >= 3:
|
|
146
|
+
try:
|
|
147
|
+
r, g, b = (int(float(nums[0])) & 255, int(float(nums[1])) & 255, int(float(nums[2])) & 255)
|
|
148
|
+
return "#%02x%02x%02x" % (r, g, b)
|
|
149
|
+
except ValueError:
|
|
150
|
+
return None
|
|
151
|
+
return None
|
|
152
|
+
if _NAMED_PASSTHROUGH.match(s):
|
|
153
|
+
return s
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_bold(props: Dict[str, Any]) -> bool:
|
|
158
|
+
"""Return whether the merged props imply a bold weight."""
|
|
159
|
+
if props.get("bold"):
|
|
160
|
+
return True
|
|
161
|
+
weight = props.get("font_weight")
|
|
162
|
+
if isinstance(weight, str):
|
|
163
|
+
return weight.lower() in _BOLD_WORDS
|
|
164
|
+
if isinstance(weight, (int, float)) and not isinstance(weight, bool):
|
|
165
|
+
return float(weight) >= 600
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _make_font(props: Dict[str, Any]) -> Any:
|
|
170
|
+
"""Build a ``tkinter.font.Font`` from the merged style props.
|
|
171
|
+
|
|
172
|
+
Sizes are passed as negative values (Tk's convention for *pixels*)
|
|
173
|
+
so the rendered text and ``measure_intrinsic`` agree with the
|
|
174
|
+
layout engine's pixel coordinate space.
|
|
175
|
+
"""
|
|
176
|
+
size = props.get("font_size")
|
|
177
|
+
try:
|
|
178
|
+
px = int(round(float(size))) if size is not None else _DEFAULT_FONT_SIZE
|
|
179
|
+
except (TypeError, ValueError):
|
|
180
|
+
px = _DEFAULT_FONT_SIZE
|
|
181
|
+
px = max(1, px)
|
|
182
|
+
kwargs: Dict[str, Any] = {
|
|
183
|
+
"size": -px,
|
|
184
|
+
"weight": "bold" if _is_bold(props) else "normal",
|
|
185
|
+
"slant": "italic" if props.get("italic") else "roman",
|
|
186
|
+
}
|
|
187
|
+
family = props.get("font_family")
|
|
188
|
+
if family:
|
|
189
|
+
kwargs["family"] = str(family)
|
|
190
|
+
decoration = props.get("text_decoration")
|
|
191
|
+
if decoration == "underline":
|
|
192
|
+
kwargs["underline"] = 1
|
|
193
|
+
elif decoration == "line_through":
|
|
194
|
+
kwargs["overstrike"] = 1
|
|
195
|
+
try:
|
|
196
|
+
return tkfont.Font(**kwargs)
|
|
197
|
+
except Exception:
|
|
198
|
+
return tkfont.Font(size=-px)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _measure_text(font: Any, text: str, max_width: float) -> Tuple[float, float]:
|
|
202
|
+
"""Return the ``(width, height)`` a string occupies in ``font``.
|
|
203
|
+
|
|
204
|
+
Honors explicit newlines and greedily word-wraps paragraphs wider
|
|
205
|
+
than ``max_width`` (``math.inf`` means no wrap) so multi-line
|
|
206
|
+
``Text`` measures the same height the engine will lay out.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
line_h = float(font.metrics("linespace"))
|
|
210
|
+
except Exception:
|
|
211
|
+
line_h = float(_DEFAULT_FONT_SIZE + 4)
|
|
212
|
+
if not text:
|
|
213
|
+
return (0.0, line_h)
|
|
214
|
+
bounded = math.isfinite(max_width) and max_width > 0
|
|
215
|
+
longest = 0.0
|
|
216
|
+
lines = 0
|
|
217
|
+
for paragraph in text.split("\n"):
|
|
218
|
+
if not paragraph:
|
|
219
|
+
lines += 1
|
|
220
|
+
continue
|
|
221
|
+
para_w = float(font.measure(paragraph))
|
|
222
|
+
if not bounded or para_w <= max_width:
|
|
223
|
+
lines += 1
|
|
224
|
+
longest = max(longest, para_w)
|
|
225
|
+
continue
|
|
226
|
+
current = ""
|
|
227
|
+
for word in paragraph.split(" "):
|
|
228
|
+
trial = word if not current else current + " " + word
|
|
229
|
+
if not current or font.measure(trial) <= max_width:
|
|
230
|
+
current = trial
|
|
231
|
+
else:
|
|
232
|
+
lines += 1
|
|
233
|
+
longest = max(longest, float(font.measure(current)))
|
|
234
|
+
current = word
|
|
235
|
+
lines += 1
|
|
236
|
+
longest = max(longest, float(font.measure(current)))
|
|
237
|
+
width = min(longest, max_width) if bounded else longest
|
|
238
|
+
return (math.ceil(width), math.ceil(lines * line_h))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _finite(value: Any, default: float = 0.0) -> float:
|
|
242
|
+
"""Coerce ``value`` to a finite float, clamping NaN/inf to ``default``."""
|
|
243
|
+
try:
|
|
244
|
+
f = float(value)
|
|
245
|
+
except (TypeError, ValueError):
|
|
246
|
+
return default
|
|
247
|
+
return f if math.isfinite(f) else default
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ======================================================================
|
|
251
|
+
# Placement (ordering-independent)
|
|
252
|
+
# ======================================================================
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _merge_props(widget: Any, props: Dict[str, Any]) -> Dict[str, Any]:
|
|
256
|
+
"""Accumulate ``props`` onto the widget so partial updates stay coherent.
|
|
257
|
+
|
|
258
|
+
The reconciler delivers only *changed* keys on update; Tk needs the
|
|
259
|
+
full picture to rebuild a font or re-derive a layout, so each
|
|
260
|
+
widget caches its merged props under ``_pn_props``.
|
|
261
|
+
"""
|
|
262
|
+
merged: Dict[str, Any] = getattr(widget, "_pn_props", None) or {}
|
|
263
|
+
merged.update(props)
|
|
264
|
+
widget._pn_props = merged
|
|
265
|
+
return merged
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _place(widget: Any) -> None:
|
|
269
|
+
"""Position ``widget`` inside its logical parent, if both are known.
|
|
270
|
+
|
|
271
|
+
Idempotent and order-independent: ``set_frame`` records the frame
|
|
272
|
+
and ``add_child`` records the parent; whichever runs second triggers
|
|
273
|
+
the actual ``place``. Coordinates compose through nested ``-in``
|
|
274
|
+
parents, so a child's parent-relative frame lands at the right
|
|
275
|
+
absolute spot.
|
|
276
|
+
"""
|
|
277
|
+
frame = getattr(widget, "_pn_frame", None)
|
|
278
|
+
if frame is None:
|
|
279
|
+
return
|
|
280
|
+
parent = getattr(widget, "_pn_parent", None)
|
|
281
|
+
target = parent if parent is not None else get_root_container()
|
|
282
|
+
if target is None:
|
|
283
|
+
return
|
|
284
|
+
x, y, w, h = frame
|
|
285
|
+
tx, ty = getattr(widget, "_pn_translate", (0.0, 0.0))
|
|
286
|
+
try:
|
|
287
|
+
widget.place(in_=target, x=x + tx, y=y + ty, width=max(0.0, w), height=max(0.0, h))
|
|
288
|
+
widget.lift()
|
|
289
|
+
except Exception:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _set_translate_from_transform(widget: Any, spec: Any) -> None:
|
|
294
|
+
"""Extract a translate offset from a ``transform`` prop for placement.
|
|
295
|
+
|
|
296
|
+
Tkinter can't scale or rotate widgets, but translation maps cleanly
|
|
297
|
+
onto ``place`` coordinates, so animated/transformed views still move
|
|
298
|
+
in the preview. Scale and rotate are ignored.
|
|
299
|
+
"""
|
|
300
|
+
tx = 0.0
|
|
301
|
+
ty = 0.0
|
|
302
|
+
if spec is not None:
|
|
303
|
+
entries = spec if isinstance(spec, list) else [spec]
|
|
304
|
+
for entry in entries:
|
|
305
|
+
if not isinstance(entry, dict):
|
|
306
|
+
continue
|
|
307
|
+
if "translate_x" in entry:
|
|
308
|
+
tx = _finite(entry["translate_x"])
|
|
309
|
+
if "translate_y" in entry:
|
|
310
|
+
ty = _finite(entry["translate_y"])
|
|
311
|
+
widget._pn_translate = (tx, ty)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _apply_common(widget: Any, props: Dict[str, Any]) -> None:
|
|
315
|
+
"""Apply visual props shared across most handlers (bg, border, transform)."""
|
|
316
|
+
if "background_color" in props:
|
|
317
|
+
color = _tk_color(props["background_color"])
|
|
318
|
+
if color is not None:
|
|
319
|
+
try:
|
|
320
|
+
widget.configure(background=color)
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
if any(k in props for k in ("border_width", "border_color")):
|
|
324
|
+
try:
|
|
325
|
+
width = props.get("border_width")
|
|
326
|
+
color = _tk_color(props.get("border_color")) or "#3c3c43"
|
|
327
|
+
if width:
|
|
328
|
+
widget.configure(
|
|
329
|
+
highlightthickness=int(round(_finite(width))),
|
|
330
|
+
highlightbackground=color,
|
|
331
|
+
highlightcolor=color,
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
widget.configure(highlightthickness=0)
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
if "transform" in props:
|
|
338
|
+
_set_translate_from_transform(widget, props["transform"])
|
|
339
|
+
_place(widget)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ======================================================================
|
|
343
|
+
# Base handler
|
|
344
|
+
# ======================================================================
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class DesktopViewHandler(ViewHandler):
|
|
348
|
+
"""Shared ``set_frame`` / child / measure behavior for Tk handlers.
|
|
349
|
+
|
|
350
|
+
Concrete handlers implement ``create`` / ``update`` (and optionally
|
|
351
|
+
``measure_intrinsic``); child management and frame application are
|
|
352
|
+
inherited and route through the order-independent
|
|
353
|
+
[`_place`][pythonnative.native_views.desktop._place] helper.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
def add_child(self, parent: Any, child: Any) -> None:
|
|
357
|
+
child._pn_parent = parent
|
|
358
|
+
_place(child)
|
|
359
|
+
|
|
360
|
+
def insert_child(self, parent: Any, child: Any, index: int) -> None:
|
|
361
|
+
child._pn_parent = parent
|
|
362
|
+
_place(child)
|
|
363
|
+
|
|
364
|
+
def remove_child(self, parent: Any, child: Any) -> None:
|
|
365
|
+
try:
|
|
366
|
+
child.place_forget()
|
|
367
|
+
except Exception:
|
|
368
|
+
pass
|
|
369
|
+
child._pn_parent = None
|
|
370
|
+
|
|
371
|
+
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
372
|
+
if native_view is None:
|
|
373
|
+
return
|
|
374
|
+
native_view._pn_frame = (_finite(x), _finite(y), max(0.0, _finite(width)), max(0.0, _finite(height)))
|
|
375
|
+
_place(native_view)
|
|
376
|
+
|
|
377
|
+
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
378
|
+
return (0.0, 0.0)
|
|
379
|
+
|
|
380
|
+
def set_animated_property(
|
|
381
|
+
self,
|
|
382
|
+
native_view: Any,
|
|
383
|
+
prop_name: str,
|
|
384
|
+
value: Any,
|
|
385
|
+
duration_ms: float = 0.0,
|
|
386
|
+
easing: str = "linear",
|
|
387
|
+
) -> None:
|
|
388
|
+
"""Apply the final value of an animated property (no tween).
|
|
389
|
+
|
|
390
|
+
The preview shows animation *end states* rather than smooth
|
|
391
|
+
interpolation. Translation maps onto placement; opacity, scale,
|
|
392
|
+
and rotation have no cheap Tk analogue and are skipped.
|
|
393
|
+
"""
|
|
394
|
+
if native_view is None:
|
|
395
|
+
return
|
|
396
|
+
if prop_name == "translate_x":
|
|
397
|
+
_, ty = getattr(native_view, "_pn_translate", (0.0, 0.0))
|
|
398
|
+
native_view._pn_translate = (_finite(value), ty)
|
|
399
|
+
_place(native_view)
|
|
400
|
+
elif prop_name == "translate_y":
|
|
401
|
+
tx, _ = getattr(native_view, "_pn_translate", (0.0, 0.0))
|
|
402
|
+
native_view._pn_translate = (tx, _finite(value))
|
|
403
|
+
_place(native_view)
|
|
404
|
+
elif prop_name == "background_color":
|
|
405
|
+
color = _tk_color(value)
|
|
406
|
+
if color is not None:
|
|
407
|
+
try:
|
|
408
|
+
native_view.configure(background=color)
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ======================================================================
|
|
414
|
+
# Containers (View / Column / Row / SafeAreaView / KeyboardAvoidingView)
|
|
415
|
+
# ======================================================================
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class FlexContainerHandler(DesktopViewHandler):
|
|
419
|
+
"""A bare positioning surface (``tk.Frame``).
|
|
420
|
+
|
|
421
|
+
All flex semantics are computed by the layout engine and applied via
|
|
422
|
+
``set_frame``; the frame only carries visual chrome (background,
|
|
423
|
+
border).
|
|
424
|
+
"""
|
|
425
|
+
|
|
426
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
427
|
+
frame = tk.Frame(_master(), highlightthickness=0, bd=0)
|
|
428
|
+
_apply_common(frame, _merge_props(frame, props))
|
|
429
|
+
return frame
|
|
430
|
+
|
|
431
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
432
|
+
_apply_common(native_view, _merge_props(native_view, changed))
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class ScrollViewHandler(FlexContainerHandler):
|
|
436
|
+
"""Preview ScrollView — a plain frame.
|
|
437
|
+
|
|
438
|
+
The layout engine still lets the content grow past the viewport on
|
|
439
|
+
the scroll axis; the desktop preview renders that overflow without
|
|
440
|
+
interactive scrolling or clipping (a documented preview limitation).
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class SafeAreaViewHandler(FlexContainerHandler):
|
|
445
|
+
"""Desktop has no notch/home-indicator insets, so this is a frame."""
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class KeyboardAvoidingViewHandler(FlexContainerHandler):
|
|
449
|
+
"""No soft keyboard on desktop; behaves as a plain frame."""
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ======================================================================
|
|
453
|
+
# Text
|
|
454
|
+
# ======================================================================
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
_ANCHOR_FOR_ALIGN = {"left": "w", "center": "center", "right": "e"}
|
|
458
|
+
_JUSTIFY_FOR_ALIGN = {"left": "left", "center": "center", "right": "right"}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class TextHandler(DesktopViewHandler):
|
|
462
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
463
|
+
label = tk.Label(_master(), highlightthickness=0, bd=0, padx=0, pady=0)
|
|
464
|
+
self._apply(label, props)
|
|
465
|
+
return label
|
|
466
|
+
|
|
467
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
468
|
+
self._apply(native_view, changed)
|
|
469
|
+
|
|
470
|
+
def _apply(self, label: Any, props: Dict[str, Any]) -> None:
|
|
471
|
+
merged = _merge_props(label, props)
|
|
472
|
+
text = merged.get("text")
|
|
473
|
+
label._pn_text = "" if text is None else str(text)
|
|
474
|
+
font = _make_font(merged)
|
|
475
|
+
label._pn_font = font
|
|
476
|
+
opts: Dict[str, Any] = {"text": label._pn_text, "font": font}
|
|
477
|
+
color = _tk_color(merged.get("color"))
|
|
478
|
+
if color is not None:
|
|
479
|
+
opts["foreground"] = color
|
|
480
|
+
align = merged.get("text_align")
|
|
481
|
+
if align in _ANCHOR_FOR_ALIGN:
|
|
482
|
+
opts["anchor"] = _ANCHOR_FOR_ALIGN[align]
|
|
483
|
+
opts["justify"] = _JUSTIFY_FOR_ALIGN[align]
|
|
484
|
+
else:
|
|
485
|
+
opts["anchor"] = "w"
|
|
486
|
+
opts["justify"] = "left"
|
|
487
|
+
try:
|
|
488
|
+
label.configure(**opts)
|
|
489
|
+
except Exception:
|
|
490
|
+
pass
|
|
491
|
+
_apply_common(label, merged)
|
|
492
|
+
|
|
493
|
+
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
494
|
+
# Wrap to the laid-out width so multi-line text flows the way the
|
|
495
|
+
# engine measured it.
|
|
496
|
+
try:
|
|
497
|
+
native_view.configure(wraplength=max(1, int(_finite(width))))
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
500
|
+
super().set_frame(native_view, x, y, width, height)
|
|
501
|
+
|
|
502
|
+
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
503
|
+
font = getattr(native_view, "_pn_font", None)
|
|
504
|
+
if font is None:
|
|
505
|
+
return (0.0, 0.0)
|
|
506
|
+
text = getattr(native_view, "_pn_text", "")
|
|
507
|
+
return _measure_text(font, text, max_width)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# ======================================================================
|
|
511
|
+
# Button
|
|
512
|
+
# ======================================================================
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class ButtonHandler(DesktopViewHandler):
|
|
516
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
517
|
+
button = tk.Button(_master(), highlightthickness=0, takefocus=0)
|
|
518
|
+
self._apply(button, props)
|
|
519
|
+
return button
|
|
520
|
+
|
|
521
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
522
|
+
self._apply(native_view, changed)
|
|
523
|
+
|
|
524
|
+
def _apply(self, button: Any, props: Dict[str, Any]) -> None:
|
|
525
|
+
merged = _merge_props(button, props)
|
|
526
|
+
title = merged.get("title")
|
|
527
|
+
button._pn_text = "" if title is None else str(title)
|
|
528
|
+
font = _make_font(merged)
|
|
529
|
+
button._pn_font = font
|
|
530
|
+
opts: Dict[str, Any] = {"text": button._pn_text, "font": font}
|
|
531
|
+
color = _tk_color(merged.get("color"))
|
|
532
|
+
if color is not None:
|
|
533
|
+
opts["foreground"] = color
|
|
534
|
+
bg = _tk_color(merged.get("background_color"))
|
|
535
|
+
if bg is not None:
|
|
536
|
+
opts["background"] = bg
|
|
537
|
+
opts["activebackground"] = bg
|
|
538
|
+
if "enabled" in merged:
|
|
539
|
+
opts["state"] = "normal" if merged.get("enabled", True) else "disabled"
|
|
540
|
+
try:
|
|
541
|
+
button.configure(**opts)
|
|
542
|
+
except Exception:
|
|
543
|
+
pass
|
|
544
|
+
if "on_click" in props:
|
|
545
|
+
callback = props["on_click"]
|
|
546
|
+
|
|
547
|
+
def _command() -> None:
|
|
548
|
+
if callable(callback):
|
|
549
|
+
try:
|
|
550
|
+
callback()
|
|
551
|
+
except Exception:
|
|
552
|
+
pass
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
button.configure(command=_command if callable(callback) else "")
|
|
556
|
+
except Exception:
|
|
557
|
+
pass
|
|
558
|
+
_apply_common(button, merged)
|
|
559
|
+
|
|
560
|
+
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
561
|
+
font = getattr(native_view, "_pn_font", None)
|
|
562
|
+
text = getattr(native_view, "_pn_text", "")
|
|
563
|
+
if font is None:
|
|
564
|
+
return (0.0, 0.0)
|
|
565
|
+
w, h = _measure_text(font, text, math.inf)
|
|
566
|
+
return (w + 28.0, h + 14.0)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# ======================================================================
|
|
570
|
+
# TextInput
|
|
571
|
+
# ======================================================================
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class TextInputHandler(DesktopViewHandler):
|
|
575
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
576
|
+
multiline = bool(props.get("multiline"))
|
|
577
|
+
widget: Any
|
|
578
|
+
if multiline:
|
|
579
|
+
widget = tk.Text(_master(), highlightthickness=1, bd=0, wrap="word", height=1)
|
|
580
|
+
else:
|
|
581
|
+
widget = tk.Entry(_master(), highlightthickness=1, bd=0)
|
|
582
|
+
widget._pn_multiline = multiline
|
|
583
|
+
widget._pn_suppress = False
|
|
584
|
+
self._bind(widget, props)
|
|
585
|
+
self._apply(widget, props)
|
|
586
|
+
return widget
|
|
587
|
+
|
|
588
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
589
|
+
self._apply(native_view, changed)
|
|
590
|
+
|
|
591
|
+
def _current_text(self, widget: Any) -> str:
|
|
592
|
+
try:
|
|
593
|
+
if getattr(widget, "_pn_multiline", False):
|
|
594
|
+
return widget.get("1.0", "end-1c")
|
|
595
|
+
return widget.get()
|
|
596
|
+
except Exception:
|
|
597
|
+
return ""
|
|
598
|
+
|
|
599
|
+
def _set_text(self, widget: Any, value: str) -> None:
|
|
600
|
+
widget._pn_suppress = True
|
|
601
|
+
try:
|
|
602
|
+
if getattr(widget, "_pn_multiline", False):
|
|
603
|
+
widget.delete("1.0", "end")
|
|
604
|
+
widget.insert("1.0", value)
|
|
605
|
+
else:
|
|
606
|
+
widget.delete(0, "end")
|
|
607
|
+
widget.insert(0, value)
|
|
608
|
+
except Exception:
|
|
609
|
+
pass
|
|
610
|
+
finally:
|
|
611
|
+
widget._pn_suppress = False
|
|
612
|
+
|
|
613
|
+
def _bind(self, widget: Any, props: Dict[str, Any]) -> None:
|
|
614
|
+
def _on_key(_event: Any = None) -> None:
|
|
615
|
+
if getattr(widget, "_pn_suppress", False):
|
|
616
|
+
return
|
|
617
|
+
callback = getattr(widget, "_pn_on_change", None)
|
|
618
|
+
if callable(callback):
|
|
619
|
+
try:
|
|
620
|
+
callback(self._current_text(widget))
|
|
621
|
+
except Exception:
|
|
622
|
+
pass
|
|
623
|
+
|
|
624
|
+
def _on_return(_event: Any = None) -> str:
|
|
625
|
+
callback = getattr(widget, "_pn_on_submit", None)
|
|
626
|
+
if callable(callback):
|
|
627
|
+
try:
|
|
628
|
+
callback(self._current_text(widget))
|
|
629
|
+
except Exception:
|
|
630
|
+
pass
|
|
631
|
+
return "break"
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
widget.bind("<KeyRelease>", _on_key)
|
|
635
|
+
if not getattr(widget, "_pn_multiline", False):
|
|
636
|
+
widget.bind("<Return>", _on_return)
|
|
637
|
+
except Exception:
|
|
638
|
+
pass
|
|
639
|
+
|
|
640
|
+
def _apply(self, widget: Any, props: Dict[str, Any]) -> None:
|
|
641
|
+
merged = _merge_props(widget, props)
|
|
642
|
+
if "on_change" in props:
|
|
643
|
+
widget._pn_on_change = props["on_change"]
|
|
644
|
+
if "on_submit" in props:
|
|
645
|
+
widget._pn_on_submit = props["on_submit"]
|
|
646
|
+
opts: Dict[str, Any] = {"font": _make_font(merged)}
|
|
647
|
+
color = _tk_color(merged.get("color"))
|
|
648
|
+
if color is not None:
|
|
649
|
+
opts["foreground"] = color
|
|
650
|
+
bg = _tk_color(merged.get("background_color"))
|
|
651
|
+
if bg is not None:
|
|
652
|
+
opts["background"] = bg
|
|
653
|
+
if not getattr(widget, "_pn_multiline", False) and merged.get("secure"):
|
|
654
|
+
opts["show"] = "\u2022"
|
|
655
|
+
if "editable" in merged:
|
|
656
|
+
opts["state"] = "normal" if merged.get("editable", True) else "disabled"
|
|
657
|
+
try:
|
|
658
|
+
widget.configure(**opts)
|
|
659
|
+
except Exception:
|
|
660
|
+
pass
|
|
661
|
+
if "value" in props:
|
|
662
|
+
incoming = "" if props["value"] is None else str(props["value"])
|
|
663
|
+
if self._current_text(widget) != incoming:
|
|
664
|
+
self._set_text(widget, incoming)
|
|
665
|
+
_apply_common(widget, merged)
|
|
666
|
+
try:
|
|
667
|
+
widget.configure(highlightbackground="#c7c7cc", highlightcolor="#007aff")
|
|
668
|
+
except Exception:
|
|
669
|
+
pass
|
|
670
|
+
|
|
671
|
+
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
672
|
+
merged = getattr(native_view, "_pn_props", {}) or {}
|
|
673
|
+
font = _make_font(merged)
|
|
674
|
+
try:
|
|
675
|
+
line_h = float(font.metrics("linespace"))
|
|
676
|
+
except Exception:
|
|
677
|
+
line_h = float(_DEFAULT_FONT_SIZE + 4)
|
|
678
|
+
return (160.0, line_h + 16.0)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
# ======================================================================
|
|
682
|
+
# Image
|
|
683
|
+
# ======================================================================
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class ImageHandler(DesktopViewHandler):
|
|
687
|
+
"""Best-effort image preview.
|
|
688
|
+
|
|
689
|
+
Tk's ``PhotoImage`` loads PNG/GIF/PPM from local paths; network URLs
|
|
690
|
+
and JPEG aren't supported without Pillow, so those fall back to a
|
|
691
|
+
labeled placeholder. The handler keeps a reference to the
|
|
692
|
+
``PhotoImage`` (Tk garbage-collects images that aren't referenced).
|
|
693
|
+
"""
|
|
694
|
+
|
|
695
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
696
|
+
label = tk.Label(_master(), highlightthickness=0, bd=0, background="#d1d1d6")
|
|
697
|
+
self._apply(label, props)
|
|
698
|
+
return label
|
|
699
|
+
|
|
700
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
701
|
+
self._apply(native_view, changed)
|
|
702
|
+
|
|
703
|
+
def _apply(self, label: Any, props: Dict[str, Any]) -> None:
|
|
704
|
+
merged = _merge_props(label, props)
|
|
705
|
+
if "source" in props:
|
|
706
|
+
source = props.get("source")
|
|
707
|
+
photo = None
|
|
708
|
+
if source and "://" not in str(source):
|
|
709
|
+
try:
|
|
710
|
+
photo = tk.PhotoImage(file=str(source))
|
|
711
|
+
except Exception:
|
|
712
|
+
photo = None
|
|
713
|
+
label._pn_photo = photo # keep a reference alive
|
|
714
|
+
try:
|
|
715
|
+
if photo is not None:
|
|
716
|
+
label.configure(image=photo, text="")
|
|
717
|
+
else:
|
|
718
|
+
name = str(source).rsplit("/", 1)[-1] if source else "image"
|
|
719
|
+
label.configure(image="", text=f"\U0001f5bc\n{name}", compound="center")
|
|
720
|
+
except Exception:
|
|
721
|
+
pass
|
|
722
|
+
_apply_common(label, merged)
|
|
723
|
+
|
|
724
|
+
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
725
|
+
photo = getattr(native_view, "_pn_photo", None)
|
|
726
|
+
if photo is not None:
|
|
727
|
+
try:
|
|
728
|
+
return (float(photo.width()), float(photo.height()))
|
|
729
|
+
except Exception:
|
|
730
|
+
pass
|
|
731
|
+
return (64.0, 64.0)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ======================================================================
|
|
735
|
+
# Switch / Checkbox
|
|
736
|
+
# ======================================================================
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
class SwitchHandler(DesktopViewHandler):
|
|
740
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
741
|
+
var = tk.IntVar(master=_master(), value=1 if props.get("value") else 0)
|
|
742
|
+
check = tk.Checkbutton(_master(), variable=var, takefocus=0, highlightthickness=0, text="")
|
|
743
|
+
check._pn_var = var
|
|
744
|
+
self._bind(check, props)
|
|
745
|
+
self._apply(check, props)
|
|
746
|
+
return check
|
|
747
|
+
|
|
748
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
749
|
+
self._apply(native_view, changed)
|
|
750
|
+
|
|
751
|
+
def _bind(self, check: Any, props: Dict[str, Any]) -> None:
|
|
752
|
+
def _command() -> None:
|
|
753
|
+
callback = getattr(check, "_pn_on_change", None)
|
|
754
|
+
if callable(callback):
|
|
755
|
+
try:
|
|
756
|
+
callback(bool(check._pn_var.get()))
|
|
757
|
+
except Exception:
|
|
758
|
+
pass
|
|
759
|
+
|
|
760
|
+
try:
|
|
761
|
+
check.configure(command=_command)
|
|
762
|
+
except Exception:
|
|
763
|
+
pass
|
|
764
|
+
|
|
765
|
+
def _apply(self, check: Any, props: Dict[str, Any]) -> None:
|
|
766
|
+
merged = _merge_props(check, props)
|
|
767
|
+
if "on_change" in props:
|
|
768
|
+
check._pn_on_change = props["on_change"]
|
|
769
|
+
if "value" in props:
|
|
770
|
+
try:
|
|
771
|
+
check._pn_var.set(1 if props.get("value") else 0)
|
|
772
|
+
except Exception:
|
|
773
|
+
pass
|
|
774
|
+
_apply_common(check, merged)
|
|
775
|
+
|
|
776
|
+
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
777
|
+
return (51.0, 31.0)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
class CheckboxHandler(DesktopViewHandler):
|
|
781
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
782
|
+
var = tk.IntVar(master=_master(), value=1 if props.get("value") else 0)
|
|
783
|
+
check = tk.Checkbutton(_master(), variable=var, takefocus=0, highlightthickness=0, anchor="w")
|
|
784
|
+
check._pn_var = var
|
|
785
|
+
self._bind(check, props)
|
|
786
|
+
self._apply(check, props)
|
|
787
|
+
return check
|
|
788
|
+
|
|
789
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
790
|
+
self._apply(native_view, changed)
|
|
791
|
+
|
|
792
|
+
def _bind(self, check: Any, props: Dict[str, Any]) -> None:
|
|
793
|
+
def _command() -> None:
|
|
794
|
+
callback = getattr(check, "_pn_on_change", None)
|
|
795
|
+
if callable(callback):
|
|
796
|
+
try:
|
|
797
|
+
callback(bool(check._pn_var.get()))
|
|
798
|
+
except Exception:
|
|
799
|
+
pass
|
|
800
|
+
|
|
801
|
+
try:
|
|
802
|
+
check.configure(command=_command)
|
|
803
|
+
except Exception:
|
|
804
|
+
pass
|
|
805
|
+
|
|
806
|
+
def _apply(self, check: Any, props: Dict[str, Any]) -> None:
|
|
807
|
+
merged = _merge_props(check, props)
|
|
808
|
+
if "on_change" in props:
|
|
809
|
+
check._pn_on_change = props["on_change"]
|
|
810
|
+
opts: Dict[str, Any] = {}
|
|
811
|
+
if "label" in merged:
|
|
812
|
+
opts["text"] = "" if merged.get("label") is None else str(merged["label"])
|
|
813
|
+
if "disabled" in merged:
|
|
814
|
+
opts["state"] = "disabled" if merged.get("disabled") else "normal"
|
|
815
|
+
color = _tk_color(merged.get("color"))
|
|
816
|
+
if color is not None:
|
|
817
|
+
opts["selectcolor"] = color
|
|
818
|
+
if opts:
|
|
819
|
+
try:
|
|
820
|
+
check.configure(**opts)
|
|
821
|
+
except Exception:
|
|
822
|
+
pass
|
|
823
|
+
if "value" in props:
|
|
824
|
+
try:
|
|
825
|
+
check._pn_var.set(1 if props.get("value") else 0)
|
|
826
|
+
except Exception:
|
|
827
|
+
pass
|
|
828
|
+
_apply_common(check, merged)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
# ======================================================================
|
|
832
|
+
# Slider / ProgressBar / ActivityIndicator
|
|
833
|
+
# ======================================================================
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
class SliderHandler(DesktopViewHandler):
|
|
837
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
838
|
+
scale = tk.Scale(
|
|
839
|
+
_master(),
|
|
840
|
+
orient="horizontal",
|
|
841
|
+
showvalue=False,
|
|
842
|
+
highlightthickness=0,
|
|
843
|
+
bd=0,
|
|
844
|
+
sliderlength=20,
|
|
845
|
+
)
|
|
846
|
+
self._bind(scale, props)
|
|
847
|
+
self._apply(scale, props)
|
|
848
|
+
return scale
|
|
849
|
+
|
|
850
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
851
|
+
self._apply(native_view, changed)
|
|
852
|
+
|
|
853
|
+
def _bind(self, scale: Any, props: Dict[str, Any]) -> None:
|
|
854
|
+
def _command(_value: Any) -> None:
|
|
855
|
+
callback = getattr(scale, "_pn_on_change", None)
|
|
856
|
+
if callable(callback):
|
|
857
|
+
try:
|
|
858
|
+
callback(float(scale.get()))
|
|
859
|
+
except Exception:
|
|
860
|
+
pass
|
|
861
|
+
|
|
862
|
+
try:
|
|
863
|
+
scale.configure(command=_command)
|
|
864
|
+
except Exception:
|
|
865
|
+
pass
|
|
866
|
+
|
|
867
|
+
def _apply(self, scale: Any, props: Dict[str, Any]) -> None:
|
|
868
|
+
merged = _merge_props(scale, props)
|
|
869
|
+
if "on_change" in props:
|
|
870
|
+
scale._pn_on_change = props["on_change"]
|
|
871
|
+
opts: Dict[str, Any] = {
|
|
872
|
+
"from_": _finite(merged.get("min_value", 0.0)),
|
|
873
|
+
"to": _finite(merged.get("max_value", 1.0)),
|
|
874
|
+
}
|
|
875
|
+
rng = opts["to"] - opts["from_"]
|
|
876
|
+
opts["resolution"] = rng / 100.0 if rng > 0 else 0.01
|
|
877
|
+
try:
|
|
878
|
+
scale.configure(**opts)
|
|
879
|
+
except Exception:
|
|
880
|
+
pass
|
|
881
|
+
if "value" in merged:
|
|
882
|
+
scale._pn_suppress = True
|
|
883
|
+
try:
|
|
884
|
+
scale.set(_finite(merged.get("value")))
|
|
885
|
+
except Exception:
|
|
886
|
+
pass
|
|
887
|
+
finally:
|
|
888
|
+
scale._pn_suppress = False
|
|
889
|
+
_apply_common(scale, merged)
|
|
890
|
+
|
|
891
|
+
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
892
|
+
width = max_width if math.isfinite(max_width) else 200.0
|
|
893
|
+
return (width, 28.0)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
class ProgressBarHandler(DesktopViewHandler):
|
|
897
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
898
|
+
bar = ttk.Progressbar(_master(), orient="horizontal", maximum=1.0)
|
|
899
|
+
self._apply(bar, props)
|
|
900
|
+
return bar
|
|
901
|
+
|
|
902
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
903
|
+
self._apply(native_view, changed)
|
|
904
|
+
|
|
905
|
+
def _apply(self, bar: Any, props: Dict[str, Any]) -> None:
|
|
906
|
+
merged = _merge_props(bar, props)
|
|
907
|
+
if merged.get("indeterminate"):
|
|
908
|
+
try:
|
|
909
|
+
bar.configure(mode="indeterminate")
|
|
910
|
+
bar.start(60)
|
|
911
|
+
except Exception:
|
|
912
|
+
pass
|
|
913
|
+
else:
|
|
914
|
+
try:
|
|
915
|
+
bar.configure(mode="determinate", value=max(0.0, min(1.0, _finite(merged.get("value", 0.0)))))
|
|
916
|
+
except Exception:
|
|
917
|
+
pass
|
|
918
|
+
|
|
919
|
+
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
920
|
+
width = max_width if math.isfinite(max_width) else 200.0
|
|
921
|
+
return (width, 6.0)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
class ActivityIndicatorHandler(DesktopViewHandler):
|
|
925
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
926
|
+
bar = ttk.Progressbar(_master(), orient="horizontal", mode="indeterminate", length=40)
|
|
927
|
+
self._apply(bar, props)
|
|
928
|
+
return bar
|
|
929
|
+
|
|
930
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
931
|
+
self._apply(native_view, changed)
|
|
932
|
+
|
|
933
|
+
def _apply(self, bar: Any, props: Dict[str, Any]) -> None:
|
|
934
|
+
merged = _merge_props(bar, props)
|
|
935
|
+
try:
|
|
936
|
+
if merged.get("animating", True):
|
|
937
|
+
bar.start(50)
|
|
938
|
+
else:
|
|
939
|
+
bar.stop()
|
|
940
|
+
except Exception:
|
|
941
|
+
pass
|
|
942
|
+
|
|
943
|
+
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
944
|
+
merged = getattr(native_view, "_pn_props", {}) or {}
|
|
945
|
+
size = 52.0 if merged.get("size") == "large" else 37.0
|
|
946
|
+
return (size, 20.0)
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
# ======================================================================
|
|
950
|
+
# Spacer / StatusBar / WebView
|
|
951
|
+
# ======================================================================
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
class SpacerHandler(DesktopViewHandler):
|
|
955
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
956
|
+
return tk.Frame(_master(), highlightthickness=0, bd=0)
|
|
957
|
+
|
|
958
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
959
|
+
pass
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
class StatusBarHandler(DesktopViewHandler):
|
|
963
|
+
"""Desktop has no system status bar; render an inert zero-size frame."""
|
|
964
|
+
|
|
965
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
966
|
+
return tk.Frame(_master(), highlightthickness=0, bd=0)
|
|
967
|
+
|
|
968
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
969
|
+
pass
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
class WebViewHandler(DesktopViewHandler):
|
|
973
|
+
"""No embedded browser on desktop; show a labeled placeholder."""
|
|
974
|
+
|
|
975
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
976
|
+
label = tk.Label(
|
|
977
|
+
_master(),
|
|
978
|
+
background="#1c1c1e",
|
|
979
|
+
foreground="#ffffff",
|
|
980
|
+
highlightthickness=0,
|
|
981
|
+
justify="center",
|
|
982
|
+
)
|
|
983
|
+
self._apply(label, props)
|
|
984
|
+
return label
|
|
985
|
+
|
|
986
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
987
|
+
self._apply(native_view, changed)
|
|
988
|
+
|
|
989
|
+
def _apply(self, label: Any, props: Dict[str, Any]) -> None:
|
|
990
|
+
merged = _merge_props(label, props)
|
|
991
|
+
target = merged.get("url") or ("inline HTML" if merged.get("html") else "")
|
|
992
|
+
try:
|
|
993
|
+
label.configure(text=f"\U0001f310 WebView\n{target}")
|
|
994
|
+
except Exception:
|
|
995
|
+
pass
|
|
996
|
+
_apply_common(label, merged)
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
# ======================================================================
|
|
1000
|
+
# Pressable
|
|
1001
|
+
# ======================================================================
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
class PressableHandler(DesktopViewHandler):
|
|
1005
|
+
"""A frame that forwards click / long-press to its callbacks."""
|
|
1006
|
+
|
|
1007
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
1008
|
+
frame = tk.Frame(_master(), highlightthickness=0, bd=0, cursor="hand2")
|
|
1009
|
+
self._bind(frame)
|
|
1010
|
+
self._apply(frame, props)
|
|
1011
|
+
return frame
|
|
1012
|
+
|
|
1013
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1014
|
+
self._apply(native_view, changed)
|
|
1015
|
+
|
|
1016
|
+
def _bind(self, frame: Any) -> None:
|
|
1017
|
+
def _on_press(_event: Any = None) -> None:
|
|
1018
|
+
callback = getattr(frame, "_pn_on_press", None)
|
|
1019
|
+
if callable(callback):
|
|
1020
|
+
try:
|
|
1021
|
+
callback()
|
|
1022
|
+
except Exception:
|
|
1023
|
+
pass
|
|
1024
|
+
|
|
1025
|
+
def _schedule_long(_event: Any = None) -> None:
|
|
1026
|
+
callback = getattr(frame, "_pn_on_long_press", None)
|
|
1027
|
+
if callable(callback):
|
|
1028
|
+
frame._pn_long_after = frame.after(500, _fire_long)
|
|
1029
|
+
|
|
1030
|
+
def _fire_long() -> None:
|
|
1031
|
+
callback = getattr(frame, "_pn_on_long_press", None)
|
|
1032
|
+
if callable(callback):
|
|
1033
|
+
try:
|
|
1034
|
+
callback()
|
|
1035
|
+
except Exception:
|
|
1036
|
+
pass
|
|
1037
|
+
|
|
1038
|
+
def _cancel_long(_event: Any = None) -> None:
|
|
1039
|
+
after_id = getattr(frame, "_pn_long_after", None)
|
|
1040
|
+
if after_id is not None:
|
|
1041
|
+
try:
|
|
1042
|
+
frame.after_cancel(after_id)
|
|
1043
|
+
except Exception:
|
|
1044
|
+
pass
|
|
1045
|
+
frame._pn_long_after = None
|
|
1046
|
+
|
|
1047
|
+
try:
|
|
1048
|
+
frame.bind("<ButtonRelease-1>", _on_press)
|
|
1049
|
+
frame.bind("<ButtonPress-1>", _schedule_long)
|
|
1050
|
+
frame.bind("<Leave>", _cancel_long)
|
|
1051
|
+
except Exception:
|
|
1052
|
+
pass
|
|
1053
|
+
|
|
1054
|
+
def _apply(self, frame: Any, props: Dict[str, Any]) -> None:
|
|
1055
|
+
merged = _merge_props(frame, props)
|
|
1056
|
+
if "on_press" in props:
|
|
1057
|
+
frame._pn_on_press = props["on_press"]
|
|
1058
|
+
if "on_long_press" in props:
|
|
1059
|
+
frame._pn_on_long_press = props["on_long_press"]
|
|
1060
|
+
_apply_common(frame, merged)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
# ======================================================================
|
|
1064
|
+
# Modal
|
|
1065
|
+
# ======================================================================
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
class ModalHandler(DesktopViewHandler):
|
|
1069
|
+
"""Overlay modal — a frame that fills the stage when ``visible``.
|
|
1070
|
+
|
|
1071
|
+
The reconciler lays the modal's content out against the full
|
|
1072
|
+
viewport (see ``Reconciler._layout_visible_modals``) and applies
|
|
1073
|
+
frames to the children, so this handler only toggles its own
|
|
1074
|
+
visibility and stacking.
|
|
1075
|
+
"""
|
|
1076
|
+
|
|
1077
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
1078
|
+
frame = tk.Frame(_master(), highlightthickness=0, bd=0, background="#ffffff")
|
|
1079
|
+
self._apply(frame, props)
|
|
1080
|
+
return frame
|
|
1081
|
+
|
|
1082
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1083
|
+
self._apply(native_view, changed)
|
|
1084
|
+
|
|
1085
|
+
def _apply(self, frame: Any, props: Dict[str, Any]) -> None:
|
|
1086
|
+
merged = _merge_props(frame, props)
|
|
1087
|
+
if merged.get("transparent"):
|
|
1088
|
+
try:
|
|
1089
|
+
frame.configure(background="#33000000".replace("33", "")) # solid fallback
|
|
1090
|
+
except Exception:
|
|
1091
|
+
pass
|
|
1092
|
+
visible = bool(merged.get("visible"))
|
|
1093
|
+
stage = get_root_container()
|
|
1094
|
+
try:
|
|
1095
|
+
if visible and stage is not None:
|
|
1096
|
+
frame.place(in_=stage, x=0, y=0, relwidth=1.0, relheight=1.0)
|
|
1097
|
+
frame.lift()
|
|
1098
|
+
else:
|
|
1099
|
+
frame.place_forget()
|
|
1100
|
+
except Exception:
|
|
1101
|
+
pass
|
|
1102
|
+
|
|
1103
|
+
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
1104
|
+
# Modal placement is driven by visibility in ``_apply``; the
|
|
1105
|
+
# engine never frames the placeholder itself.
|
|
1106
|
+
return
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
# ======================================================================
|
|
1110
|
+
# TabBar
|
|
1111
|
+
# ======================================================================
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
class TabBarHandler(DesktopViewHandler):
|
|
1115
|
+
"""Bottom tab bar — a row of buttons laid out across its width."""
|
|
1116
|
+
|
|
1117
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
1118
|
+
frame = tk.Frame(_master(), highlightthickness=1, bd=0, background="#f2f2f7")
|
|
1119
|
+
try:
|
|
1120
|
+
frame.configure(highlightbackground="#c6c6c8", highlightcolor="#c6c6c8")
|
|
1121
|
+
except Exception:
|
|
1122
|
+
pass
|
|
1123
|
+
frame._pn_buttons = []
|
|
1124
|
+
self._apply(frame, props)
|
|
1125
|
+
return frame
|
|
1126
|
+
|
|
1127
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1128
|
+
self._apply(native_view, changed)
|
|
1129
|
+
|
|
1130
|
+
def _apply(self, frame: Any, props: Dict[str, Any]) -> None:
|
|
1131
|
+
merged = _merge_props(frame, props)
|
|
1132
|
+
items: List[Dict[str, Any]] = merged.get("items") or []
|
|
1133
|
+
active = merged.get("active_tab")
|
|
1134
|
+
on_select = merged.get("on_tab_select")
|
|
1135
|
+
for button in getattr(frame, "_pn_buttons", []):
|
|
1136
|
+
try:
|
|
1137
|
+
button.destroy()
|
|
1138
|
+
except Exception:
|
|
1139
|
+
pass
|
|
1140
|
+
buttons: List[Any] = []
|
|
1141
|
+
for item in items:
|
|
1142
|
+
name = item.get("name")
|
|
1143
|
+
title = item.get("title", name)
|
|
1144
|
+
is_active = name == active
|
|
1145
|
+
|
|
1146
|
+
def _make_cmd(tab_name: Any) -> Any:
|
|
1147
|
+
def _cmd() -> None:
|
|
1148
|
+
if callable(on_select):
|
|
1149
|
+
try:
|
|
1150
|
+
on_select(tab_name)
|
|
1151
|
+
except Exception:
|
|
1152
|
+
pass
|
|
1153
|
+
|
|
1154
|
+
return _cmd
|
|
1155
|
+
|
|
1156
|
+
button = tk.Button(
|
|
1157
|
+
frame,
|
|
1158
|
+
text=str(title),
|
|
1159
|
+
command=_make_cmd(name),
|
|
1160
|
+
relief="flat",
|
|
1161
|
+
takefocus=0,
|
|
1162
|
+
highlightthickness=0,
|
|
1163
|
+
foreground="#007aff" if is_active else "#8e8e93",
|
|
1164
|
+
background="#f2f2f7",
|
|
1165
|
+
activebackground="#e5e5ea",
|
|
1166
|
+
borderwidth=0,
|
|
1167
|
+
)
|
|
1168
|
+
buttons.append(button)
|
|
1169
|
+
frame._pn_buttons = buttons
|
|
1170
|
+
self._layout_buttons(frame)
|
|
1171
|
+
|
|
1172
|
+
def _layout_buttons(self, frame: Any) -> None:
|
|
1173
|
+
buttons = getattr(frame, "_pn_buttons", [])
|
|
1174
|
+
count = len(buttons)
|
|
1175
|
+
if count == 0:
|
|
1176
|
+
return
|
|
1177
|
+
frame_w, frame_h = getattr(frame, "_pn_size", (0.0, 0.0))
|
|
1178
|
+
if frame_w <= 0:
|
|
1179
|
+
return
|
|
1180
|
+
each = frame_w / count
|
|
1181
|
+
for i, button in enumerate(buttons):
|
|
1182
|
+
try:
|
|
1183
|
+
button.place(x=i * each, y=0, width=each, height=frame_h)
|
|
1184
|
+
except Exception:
|
|
1185
|
+
pass
|
|
1186
|
+
|
|
1187
|
+
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
1188
|
+
native_view._pn_size = (max(0.0, _finite(width)), max(0.0, _finite(height)))
|
|
1189
|
+
super().set_frame(native_view, x, y, width, height)
|
|
1190
|
+
self._layout_buttons(native_view)
|
|
1191
|
+
|
|
1192
|
+
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
1193
|
+
width = max_width if math.isfinite(max_width) else 320.0
|
|
1194
|
+
return (width, 49.0)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
# ======================================================================
|
|
1198
|
+
# Picker / SegmentedControl / DatePicker
|
|
1199
|
+
# ======================================================================
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
class PickerHandler(DesktopViewHandler):
|
|
1203
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
1204
|
+
combo = ttk.Combobox(_master(), state="readonly")
|
|
1205
|
+
self._bind(combo)
|
|
1206
|
+
self._apply(combo, props)
|
|
1207
|
+
return combo
|
|
1208
|
+
|
|
1209
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1210
|
+
self._apply(native_view, changed)
|
|
1211
|
+
|
|
1212
|
+
def _bind(self, combo: Any) -> None:
|
|
1213
|
+
def _on_select(_event: Any = None) -> None:
|
|
1214
|
+
callback = getattr(combo, "_pn_on_change", None)
|
|
1215
|
+
items = getattr(combo, "_pn_items", [])
|
|
1216
|
+
idx = combo.current()
|
|
1217
|
+
if callable(callback) and 0 <= idx < len(items):
|
|
1218
|
+
try:
|
|
1219
|
+
callback(items[idx].get("value"))
|
|
1220
|
+
except Exception:
|
|
1221
|
+
pass
|
|
1222
|
+
|
|
1223
|
+
try:
|
|
1224
|
+
combo.bind("<<ComboboxSelected>>", _on_select)
|
|
1225
|
+
except Exception:
|
|
1226
|
+
pass
|
|
1227
|
+
|
|
1228
|
+
def _apply(self, combo: Any, props: Dict[str, Any]) -> None:
|
|
1229
|
+
merged = _merge_props(combo, props)
|
|
1230
|
+
if "on_change" in props:
|
|
1231
|
+
combo._pn_on_change = props["on_change"]
|
|
1232
|
+
items: List[Dict[str, Any]] = merged.get("items") or []
|
|
1233
|
+
combo._pn_items = items
|
|
1234
|
+
labels = [str(item.get("label", item.get("value", ""))) for item in items]
|
|
1235
|
+
try:
|
|
1236
|
+
combo.configure(values=labels)
|
|
1237
|
+
except Exception:
|
|
1238
|
+
pass
|
|
1239
|
+
if "value" in merged:
|
|
1240
|
+
target = merged.get("value")
|
|
1241
|
+
for i, item in enumerate(items):
|
|
1242
|
+
if item.get("value") == target:
|
|
1243
|
+
try:
|
|
1244
|
+
combo.current(i)
|
|
1245
|
+
except Exception:
|
|
1246
|
+
pass
|
|
1247
|
+
break
|
|
1248
|
+
|
|
1249
|
+
def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
|
|
1250
|
+
return (180.0, 30.0)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
class SegmentedControlHandler(DesktopViewHandler):
|
|
1254
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
1255
|
+
frame = tk.Frame(_master(), highlightthickness=0, bd=0)
|
|
1256
|
+
frame._pn_buttons = []
|
|
1257
|
+
self._apply(frame, props)
|
|
1258
|
+
return frame
|
|
1259
|
+
|
|
1260
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1261
|
+
self._apply(native_view, changed)
|
|
1262
|
+
|
|
1263
|
+
def _apply(self, frame: Any, props: Dict[str, Any]) -> None:
|
|
1264
|
+
merged = _merge_props(frame, props)
|
|
1265
|
+
segments: List[str] = merged.get("segments") or []
|
|
1266
|
+
selected = int(merged.get("selected_index", 0) or 0)
|
|
1267
|
+
on_change = merged.get("on_change")
|
|
1268
|
+
tint = _tk_color(merged.get("tint_color")) or "#007aff"
|
|
1269
|
+
for button in getattr(frame, "_pn_buttons", []):
|
|
1270
|
+
try:
|
|
1271
|
+
button.destroy()
|
|
1272
|
+
except Exception:
|
|
1273
|
+
pass
|
|
1274
|
+
buttons: List[Any] = []
|
|
1275
|
+
for i, label in enumerate(segments):
|
|
1276
|
+
is_active = i == selected
|
|
1277
|
+
|
|
1278
|
+
def _make_cmd(index: int) -> Any:
|
|
1279
|
+
def _cmd() -> None:
|
|
1280
|
+
if callable(on_change):
|
|
1281
|
+
try:
|
|
1282
|
+
on_change(index)
|
|
1283
|
+
except Exception:
|
|
1284
|
+
pass
|
|
1285
|
+
|
|
1286
|
+
return _cmd
|
|
1287
|
+
|
|
1288
|
+
button = tk.Button(
|
|
1289
|
+
frame,
|
|
1290
|
+
text=str(label),
|
|
1291
|
+
command=_make_cmd(i),
|
|
1292
|
+
relief="flat",
|
|
1293
|
+
takefocus=0,
|
|
1294
|
+
highlightthickness=1,
|
|
1295
|
+
borderwidth=1,
|
|
1296
|
+
foreground="#ffffff" if is_active else tint,
|
|
1297
|
+
background=tint if is_active else "#ffffff",
|
|
1298
|
+
)
|
|
1299
|
+
try:
|
|
1300
|
+
state = "normal" if merged.get("enabled", True) else "disabled"
|
|
1301
|
+
button.configure({"highlightbackground": tint, "state": state})
|
|
1302
|
+
except Exception:
|
|
1303
|
+
pass
|
|
1304
|
+
buttons.append(button)
|
|
1305
|
+
frame._pn_buttons = buttons
|
|
1306
|
+
self._layout_buttons(frame)
|
|
1307
|
+
|
|
1308
|
+
def _layout_buttons(self, frame: Any) -> None:
|
|
1309
|
+
buttons = getattr(frame, "_pn_buttons", [])
|
|
1310
|
+
count = len(buttons)
|
|
1311
|
+
if count == 0:
|
|
1312
|
+
return
|
|
1313
|
+
frame_w, frame_h = getattr(frame, "_pn_size", (0.0, 0.0))
|
|
1314
|
+
if frame_w <= 0:
|
|
1315
|
+
return
|
|
1316
|
+
each = frame_w / count
|
|
1317
|
+
for i, button in enumerate(buttons):
|
|
1318
|
+
try:
|
|
1319
|
+
button.place(x=i * each, y=0, width=each, height=frame_h)
|
|
1320
|
+
except Exception:
|
|
1321
|
+
pass
|
|
1322
|
+
|
|
1323
|
+
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
1324
|
+
native_view._pn_size = (max(0.0, _finite(width)), max(0.0, _finite(height)))
|
|
1325
|
+
super().set_frame(native_view, x, y, width, height)
|
|
1326
|
+
self._layout_buttons(native_view)
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
class DatePickerHandler(DesktopViewHandler):
|
|
1330
|
+
"""Preview DatePicker — a text entry for the ISO date/time string."""
|
|
1331
|
+
|
|
1332
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
1333
|
+
entry = tk.Entry(_master(), highlightthickness=1, bd=0)
|
|
1334
|
+
self._bind(entry)
|
|
1335
|
+
self._apply(entry, props)
|
|
1336
|
+
return entry
|
|
1337
|
+
|
|
1338
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1339
|
+
self._apply(native_view, changed)
|
|
1340
|
+
|
|
1341
|
+
def _bind(self, entry: Any) -> None:
|
|
1342
|
+
def _on_key(_event: Any = None) -> None:
|
|
1343
|
+
callback = getattr(entry, "_pn_on_change", None)
|
|
1344
|
+
if callable(callback):
|
|
1345
|
+
try:
|
|
1346
|
+
callback(entry.get())
|
|
1347
|
+
except Exception:
|
|
1348
|
+
pass
|
|
1349
|
+
|
|
1350
|
+
try:
|
|
1351
|
+
entry.bind("<KeyRelease>", _on_key)
|
|
1352
|
+
except Exception:
|
|
1353
|
+
pass
|
|
1354
|
+
|
|
1355
|
+
def _apply(self, entry: Any, props: Dict[str, Any]) -> None:
|
|
1356
|
+
merged = _merge_props(entry, props)
|
|
1357
|
+
if "on_change" in props:
|
|
1358
|
+
entry._pn_on_change = props["on_change"]
|
|
1359
|
+
if "enabled" in merged:
|
|
1360
|
+
try:
|
|
1361
|
+
entry.configure(state="normal" if merged.get("enabled", True) else "disabled")
|
|
1362
|
+
except Exception:
|
|
1363
|
+
pass
|
|
1364
|
+
if "value" in props:
|
|
1365
|
+
incoming = "" if props["value"] is None else str(props["value"])
|
|
1366
|
+
try:
|
|
1367
|
+
if entry.get() != incoming:
|
|
1368
|
+
state = str(entry.cget("state"))
|
|
1369
|
+
entry.configure(state="normal")
|
|
1370
|
+
entry.delete(0, "end")
|
|
1371
|
+
entry.insert(0, incoming)
|
|
1372
|
+
entry.configure(state=state)
|
|
1373
|
+
except Exception:
|
|
1374
|
+
pass
|
|
1375
|
+
try:
|
|
1376
|
+
entry.configure(highlightbackground="#c7c7cc", highlightcolor="#007aff")
|
|
1377
|
+
except Exception:
|
|
1378
|
+
pass
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
# ======================================================================
|
|
1382
|
+
# VirtualList (FlatList / SectionList)
|
|
1383
|
+
# ======================================================================
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
class VirtualListHandler(DesktopViewHandler):
|
|
1387
|
+
"""Preview list — eagerly mounts a bounded window of rows.
|
|
1388
|
+
|
|
1389
|
+
The native iOS/Android backends recycle cells; the desktop preview
|
|
1390
|
+
mounts up to [`_MAX_ROWS`][pythonnative.native_views.desktop.VirtualListHandler]
|
|
1391
|
+
rows into per-row cells once its frame is known. Each cell is handed
|
|
1392
|
+
to the ``mount_row`` callback supplied by
|
|
1393
|
+
[`FlatList`][pythonnative.FlatList] / [`SectionList`][pythonnative.SectionList],
|
|
1394
|
+
which mounts the row's element subtree through a nested reconciler.
|
|
1395
|
+
"""
|
|
1396
|
+
|
|
1397
|
+
_MAX_ROWS = 200
|
|
1398
|
+
|
|
1399
|
+
def create(self, props: Dict[str, Any]) -> Any:
|
|
1400
|
+
frame = tk.Frame(_master(), highlightthickness=0, bd=0)
|
|
1401
|
+
frame._pn_rows = []
|
|
1402
|
+
frame._pn_mounted = False
|
|
1403
|
+
self._apply(frame, props)
|
|
1404
|
+
return frame
|
|
1405
|
+
|
|
1406
|
+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
|
|
1407
|
+
was_count = (getattr(native_view, "_pn_props", {}) or {}).get("count")
|
|
1408
|
+
self._apply(native_view, changed)
|
|
1409
|
+
if "count" in changed and changed.get("count") != was_count:
|
|
1410
|
+
native_view._pn_mounted = False
|
|
1411
|
+
self._mount_rows(native_view)
|
|
1412
|
+
|
|
1413
|
+
def _apply(self, frame: Any, props: Dict[str, Any]) -> Dict[str, Any]:
|
|
1414
|
+
merged = _merge_props(frame, props)
|
|
1415
|
+
_apply_common(frame, merged)
|
|
1416
|
+
return merged
|
|
1417
|
+
|
|
1418
|
+
def _mount_rows(self, frame: Any) -> None:
|
|
1419
|
+
if getattr(frame, "_pn_mounted", False):
|
|
1420
|
+
return
|
|
1421
|
+
merged = getattr(frame, "_pn_props", {}) or {}
|
|
1422
|
+
count = int(merged.get("count", 0) or 0)
|
|
1423
|
+
row_height = _finite(merged.get("row_height", 0.0))
|
|
1424
|
+
mount_row = merged.get("mount_row")
|
|
1425
|
+
frame_w, _frame_h = getattr(frame, "_pn_size", (0.0, 0.0))
|
|
1426
|
+
if count <= 0 or row_height <= 0 or not callable(mount_row) or frame_w <= 0:
|
|
1427
|
+
return
|
|
1428
|
+
for cell in getattr(frame, "_pn_rows", []):
|
|
1429
|
+
try:
|
|
1430
|
+
cell.destroy()
|
|
1431
|
+
except Exception:
|
|
1432
|
+
pass
|
|
1433
|
+
rows: List[Any] = []
|
|
1434
|
+
for index in range(min(count, self._MAX_ROWS)):
|
|
1435
|
+
cell = tk.Frame(_master(), highlightthickness=0, bd=0)
|
|
1436
|
+
cell._pn_parent = frame
|
|
1437
|
+
cell._pn_frame = (0.0, index * row_height, frame_w, row_height)
|
|
1438
|
+
_place(cell)
|
|
1439
|
+
rows.append(cell)
|
|
1440
|
+
try:
|
|
1441
|
+
mount_row(index, cell, frame_w, row_height)
|
|
1442
|
+
except Exception:
|
|
1443
|
+
pass
|
|
1444
|
+
frame._pn_rows = rows
|
|
1445
|
+
frame._pn_mounted = True
|
|
1446
|
+
|
|
1447
|
+
def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
|
|
1448
|
+
native_view._pn_size = (max(0.0, _finite(width)), max(0.0, _finite(height)))
|
|
1449
|
+
super().set_frame(native_view, x, y, width, height)
|
|
1450
|
+
self._mount_rows(native_view)
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
# ======================================================================
|
|
1454
|
+
# Registration
|
|
1455
|
+
# ======================================================================
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def register_handlers(registry: Any) -> None:
|
|
1459
|
+
"""Register every built-in desktop handler on ``registry``.
|
|
1460
|
+
|
|
1461
|
+
Mirrors ``register_handlers`` in the iOS / Android backends so the
|
|
1462
|
+
desktop registry services the same 25 element types.
|
|
1463
|
+
"""
|
|
1464
|
+
flex = FlexContainerHandler()
|
|
1465
|
+
registry.register("View", flex)
|
|
1466
|
+
registry.register("Column", flex)
|
|
1467
|
+
registry.register("Row", flex)
|
|
1468
|
+
registry.register("Text", TextHandler())
|
|
1469
|
+
registry.register("Button", ButtonHandler())
|
|
1470
|
+
registry.register("TextInput", TextInputHandler())
|
|
1471
|
+
registry.register("Image", ImageHandler())
|
|
1472
|
+
registry.register("Switch", SwitchHandler())
|
|
1473
|
+
registry.register("ProgressBar", ProgressBarHandler())
|
|
1474
|
+
registry.register("ActivityIndicator", ActivityIndicatorHandler())
|
|
1475
|
+
registry.register("WebView", WebViewHandler())
|
|
1476
|
+
registry.register("Spacer", SpacerHandler())
|
|
1477
|
+
registry.register("ScrollView", ScrollViewHandler())
|
|
1478
|
+
registry.register("SafeAreaView", SafeAreaViewHandler())
|
|
1479
|
+
registry.register("Modal", ModalHandler())
|
|
1480
|
+
registry.register("Slider", SliderHandler())
|
|
1481
|
+
registry.register("TabBar", TabBarHandler())
|
|
1482
|
+
registry.register("Pressable", PressableHandler())
|
|
1483
|
+
registry.register("StatusBar", StatusBarHandler())
|
|
1484
|
+
registry.register("KeyboardAvoidingView", KeyboardAvoidingViewHandler())
|
|
1485
|
+
registry.register("VirtualList", VirtualListHandler())
|
|
1486
|
+
registry.register("Picker", PickerHandler())
|
|
1487
|
+
registry.register("Checkbox", CheckboxHandler())
|
|
1488
|
+
registry.register("SegmentedControl", SegmentedControlHandler())
|
|
1489
|
+
registry.register("DatePicker", DatePickerHandler())
|