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.
@@ -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())