pythonnative 0.25.0__py3-none-any.whl → 0.27.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.
Files changed (50) hide show
  1. pythonnative/__init__.py +1 -1
  2. pythonnative/animated.py +697 -62
  3. pythonnative/cli/pn.py +172 -12
  4. pythonnative/components.py +61 -7
  5. pythonnative/diagnostics.py +27 -0
  6. pythonnative/gestures.py +446 -34
  7. pythonnative/native_modules/app_state.py +2 -1
  8. pythonnative/native_modules/battery.py +3 -2
  9. pythonnative/native_modules/biometrics.py +2 -1
  10. pythonnative/native_modules/camera.py +16 -7
  11. pythonnative/native_modules/clipboard.py +3 -2
  12. pythonnative/native_modules/file_system.py +3 -2
  13. pythonnative/native_modules/haptics.py +7 -6
  14. pythonnative/native_modules/linking.py +62 -6
  15. pythonnative/native_modules/location.py +11 -8
  16. pythonnative/native_modules/net_info.py +75 -5
  17. pythonnative/native_modules/notifications.py +134 -15
  18. pythonnative/native_modules/permissions.py +3 -2
  19. pythonnative/native_modules/share.py +2 -1
  20. pythonnative/native_views/android.py +461 -137
  21. pythonnative/native_views/desktop.py +816 -96
  22. pythonnative/native_views/ios.py +750 -193
  23. pythonnative/project/android.py +15 -3
  24. pythonnative/project/builder.py +172 -70
  25. pythonnative/project/config.py +28 -6
  26. pythonnative/project/devices.py +291 -0
  27. pythonnative/project/doctor.py +5 -4
  28. pythonnative/project/ios.py +51 -77
  29. pythonnative/project/permissions.py +19 -1
  30. pythonnative/project/runtime_assets.py +78 -166
  31. pythonnative/reconciler.py +28 -0
  32. pythonnative/style.py +19 -0
  33. pythonnative/templates/android_template/app/build.gradle +9 -0
  34. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +5 -0
  35. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +148 -12
  36. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNFrameLayout.kt +50 -0
  37. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +41 -17
  38. pythonnative/templates/ios_template/ios_template/BridgingHeader.h +14 -0
  39. pythonnative/templates/ios_template/ios_template/Info.plist +5 -2
  40. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +397 -0
  41. pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +31 -20
  42. pythonnative/templates/ios_template/ios_template/ViewController.swift +77 -198
  43. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +72 -47
  44. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/METADATA +3 -3
  45. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/RECORD +49 -46
  46. pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
  47. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/WHEEL +0 -0
  48. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/entry_points.txt +0 -0
  49. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/licenses/LICENSE +0 -0
  50. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/top_level.txt +0 -0
@@ -36,15 +36,45 @@ parent-relative frames render correctly without reparenting.
36
36
  ScrollViews shift their children's placement by the current scroll
37
37
  offset, which yields real wheel scrolling in the preview.
38
38
 
39
+ Interaction and stacking props
40
+ ------------------------------
41
+ ``z_index`` is honored among logical siblings: whenever a sibling set
42
+ contains an explicit ``z_index``, the parent's children are re-lifted
43
+ in ascending ``z_index`` order (missing values count as 0, ties keep
44
+ insertion order), so higher values render above their siblings.
45
+ ``pointer_events`` is approximated by suspending a widget's Tk binding
46
+ tags: ``"none"`` mutes the view and its descendants, ``"box_none"``
47
+ mutes only the view itself, and ``"box_only"`` mutes only the
48
+ descendants. Suspension is reversible (the original tags are restored
49
+ when the prop returns to ``"auto"``), but it is coarser than the real
50
+ platforms: all bindings go quiet (keyboard included), and muted
51
+ widgets drop events rather than passing them through to whatever sits
52
+ underneath. ``hit_slop`` can't grow the initial press target (Tk
53
+ hit-tests presses strictly by widget bounds), but it does expand press
54
+ *tracking*: once a press starts, Tk's implicit grab keeps streaming
55
+ motion and release events to the pressed widget, and ``Pressable``
56
+ treats positions within the slop insets of its frame as still inside
57
+ when deciding whether a release fires ``on_press`` or a pending long
58
+ press survives pointer drift.
59
+
39
60
  Scope
40
61
  -----
41
62
  This is a **preview** backend, not a production desktop target. It
42
63
  favors fidelity of layout and behavior over pixel-perfect chrome:
43
- rounded corners, shadows, per-widget opacity, and overflow clipping are
44
- approximated or omitted (Tkinter can't express them cheaply). Native
45
- animation is declined (``start_animation`` returns ``False``), so the
46
- Python ticker drives previews of animations through
47
- ``set_animated_property``.
64
+ shadows, per-widget opacity, and overflow clipping are approximated or
65
+ omitted (Tkinter can't express them cheaply). Rounded corners
66
+ (``border_radius`` plus the per-corner ``border_*_radius`` keys, which
67
+ fall back to it) are approximated on Frame-based views by painting the
68
+ background and a uniform border onto a covering ``Canvas``; the corner
69
+ cutouts are filled with the parent's solid background rather than
70
+ being truly transparent, and content leaves (Text, Button, Image)
71
+ ignore radius entirely. Native animation is declined
72
+ (``start_animation`` returns ``False``), so the Python ticker drives
73
+ previews of animations through ``set_animated_property``: translation
74
+ maps onto placement, animated ``background_color`` and ``color``
75
+ (including interpolated ``"#AARRGGBB"`` strings) map onto ``configure``
76
+ (or the rounded-corner canvas when one is active), and rotate, scale,
77
+ and opacity frames are silently ignored.
48
78
 
49
79
  This module imports ``tkinter`` at import time, so it is only imported
50
80
  when ``PN_PLATFORM=desktop``. Off-device unit tests inject a mock
@@ -54,6 +84,7 @@ and never trigger this path.
54
84
 
55
85
  from __future__ import annotations
56
86
 
87
+ import bisect
57
88
  import math
58
89
  import re
59
90
  import time
@@ -62,6 +93,7 @@ from tkinter import font as tkfont
62
93
  from tkinter import ttk
63
94
  from typing import Any, Dict, List, Optional, Tuple
64
95
 
96
+ from .. import diagnostics
65
97
  from ..events import dispatch_event, event_names
66
98
  from ..gestures import make_arbiter
67
99
  from .base import ViewHandler
@@ -325,7 +357,17 @@ def _place(widget: Any) -> None:
325
357
  widget.place(in_=target, x=x + tx - sx, y=y + ty - sy, width=max(0.0, w), height=max(0.0, h))
326
358
  widget.lift()
327
359
  except Exception:
328
- pass
360
+ diagnostics.swallowed("desktop._place")
361
+ return
362
+ # The unconditional lift above would put a low ``z_index`` sibling
363
+ # on top, so restack whenever this sibling set uses ``z_index``.
364
+ if parent is not None and getattr(parent, "_pn_has_z", False):
365
+ _restack(parent)
366
+ # Frames are the only reliable resize signal for unmapped widgets,
367
+ # so keep the rounded background in sync here (cheap when the
368
+ # paint signature is unchanged).
369
+ if getattr(widget, "_pn_radius_canvas", None) is not None:
370
+ _redraw_rounded_background(widget)
329
371
 
330
372
 
331
373
  def _register_child(parent: Any, child: Any, index: int) -> None:
@@ -346,6 +388,369 @@ def _unregister_child(parent: Any, child: Any) -> None:
346
388
  parent._pn_children = children
347
389
 
348
390
 
391
+ def _z_index(widget: Any) -> float:
392
+ """Return the widget's ``z_index`` (missing or unparseable is 0)."""
393
+ merged = getattr(widget, "_pn_props", None) or {}
394
+ z = merged.get("z_index")
395
+ if z is None:
396
+ return 0.0
397
+ try:
398
+ return float(z)
399
+ except (TypeError, ValueError):
400
+ return 0.0
401
+
402
+
403
+ def _lift_subtree(widget: Any) -> None:
404
+ """Lift ``widget``, then its logical descendants in ``z_index`` order.
405
+
406
+ Every desktop widget is a Tk child of the shared stage, so
407
+ stacking is one global order: lifting a container doesn't carry
408
+ its subtree along. Pre-order lifting keeps each subtree above its
409
+ root and applies nested ``z_index`` ordering along the way.
410
+ """
411
+ try:
412
+ widget.lift()
413
+ except Exception:
414
+ diagnostics.swallowed("desktop._lift_subtree")
415
+ for child in sorted(getattr(widget, "_pn_children", None) or [], key=_z_index):
416
+ _lift_subtree(child)
417
+
418
+
419
+ def _restack(parent: Any) -> None:
420
+ """Re-lift ``parent``'s children (and their subtrees) by ``z_index``.
421
+
422
+ ``sorted`` is stable, so siblings with equal ``z_index`` keep their
423
+ insertion order. Called whenever a sibling set contains an explicit
424
+ ``z_index`` (see ``_place`` and ``_apply_common``).
425
+ """
426
+ for child in sorted(getattr(parent, "_pn_children", None) or [], key=_z_index):
427
+ _lift_subtree(child)
428
+
429
+
430
+ # ----------------------------------------------------------------------
431
+ # ``pointer_events`` (best-effort via Tk binding tags)
432
+ # ----------------------------------------------------------------------
433
+ #
434
+ # A muted widget gets a single dummy binding tag with no bindings, so
435
+ # no event scripts (its own, its class's, or the global "all" tag's)
436
+ # run while the mode is active; the original tags are saved and
437
+ # restored when the prop returns to "auto". This is coarser than the
438
+ # real platforms: keyboard bindings go quiet too, and Tk still routes
439
+ # the event to the widget under the cursor, so muted widgets drop
440
+ # events instead of letting them fall through to lower widgets.
441
+
442
+ _PE_MUTED_TAG = "pn_pointer_events_off"
443
+
444
+
445
+ def _pointer_mode(widget: Any) -> str:
446
+ """Return the widget's own ``pointer_events`` mode (default ``"auto"``)."""
447
+ merged = getattr(widget, "_pn_props", None) or {}
448
+ mode = merged.get("pointer_events")
449
+ return mode if mode in ("none", "box_none", "box_only") else "auto"
450
+
451
+
452
+ def _pointer_muted(widget: Any) -> bool:
453
+ """Whether the widget should ignore pointer events right now.
454
+
455
+ True when its own mode mutes the view (``"none"`` / ``"box_none"``)
456
+ or when any logical ancestor mutes its subtree (``"none"`` /
457
+ ``"box_only"``).
458
+ """
459
+ if _pointer_mode(widget) in ("none", "box_none"):
460
+ return True
461
+ node = getattr(widget, "_pn_parent", None)
462
+ while node is not None:
463
+ if _pointer_mode(node) in ("none", "box_only"):
464
+ return True
465
+ node = getattr(node, "_pn_parent", None)
466
+ return False
467
+
468
+
469
+ def _set_pointer_muted(widget: Any, muted: bool) -> None:
470
+ """Suspend or restore the widget's Tk binding tags (idempotent)."""
471
+ try:
472
+ current = tuple(widget.bindtags())
473
+ if muted:
474
+ if current != (_PE_MUTED_TAG,):
475
+ widget._pn_saved_bindtags = current
476
+ widget.bindtags((_PE_MUTED_TAG,))
477
+ elif current == (_PE_MUTED_TAG,):
478
+ saved = getattr(widget, "_pn_saved_bindtags", None)
479
+ if saved:
480
+ widget.bindtags(saved)
481
+ except Exception:
482
+ diagnostics.swallowed("desktop._set_pointer_muted")
483
+
484
+
485
+ def _refresh_pointer_events(widget: Any) -> None:
486
+ """Recompute pointer muting for ``widget`` and its logical subtree."""
487
+ muted = _pointer_muted(widget)
488
+ _set_pointer_muted(widget, muted)
489
+ canvas = getattr(widget, "_pn_radius_canvas", None)
490
+ if canvas is not None:
491
+ # The rounded-corner canvas covers the widget and forwards
492
+ # events to it (see ``_set_rounded_background``), so it mutes
493
+ # exactly when its host does.
494
+ _set_pointer_muted(canvas, muted)
495
+ for child in getattr(widget, "_pn_children", None) or []:
496
+ _refresh_pointer_events(child)
497
+
498
+
499
+ # ----------------------------------------------------------------------
500
+ # ``hit_slop`` (press-tracking expansion)
501
+ # ----------------------------------------------------------------------
502
+ #
503
+ # Tk delivers the initial press only to the widget under the cursor,
504
+ # so the slop insets can't grow the press target the way they do on
505
+ # device. They do expand the *tracking* rect: Tk's implicit grab keeps
506
+ # streaming motion and release events to the pressed widget even past
507
+ # its edges, and ``_within_hit_rect`` treats positions within the slop
508
+ # insets of the widget's frame as inside.
509
+
510
+
511
+ def _parse_hit_slop(value: Any) -> Tuple[float, float, float, float]:
512
+ """Normalize a ``hit_slop`` prop into ``(top, left, bottom, right)`` insets.
513
+
514
+ Accepts a uniform number or a dict with any of ``top`` / ``left``
515
+ / ``bottom`` / ``right``; missing or unparseable entries are 0.
516
+ """
517
+ if isinstance(value, dict):
518
+ return (
519
+ max(0.0, _finite(value.get("top"))),
520
+ max(0.0, _finite(value.get("left"))),
521
+ max(0.0, _finite(value.get("bottom"))),
522
+ max(0.0, _finite(value.get("right"))),
523
+ )
524
+ uniform = max(0.0, _finite(value))
525
+ return (uniform, uniform, uniform, uniform)
526
+
527
+
528
+ def _within_hit_rect(widget: Any, x: float, y: float) -> bool:
529
+ """Whether a widget-relative point is inside the frame plus ``hit_slop``.
530
+
531
+ Falls back to the live Tk size when the layout frame isn't known
532
+ yet, and to "inside" when neither is available (never drop a press
533
+ for lack of geometry).
534
+ """
535
+ frame = getattr(widget, "_pn_frame", None)
536
+ if frame is not None:
537
+ w, h = frame[2], frame[3]
538
+ else:
539
+ try:
540
+ w, h = float(widget.winfo_width()), float(widget.winfo_height())
541
+ except Exception:
542
+ return True
543
+ top, left, bottom, right = getattr(widget, "_pn_hit_slop", (0.0, 0.0, 0.0, 0.0))
544
+ return (-left <= x <= w + right) and (-top <= y <= h + bottom)
545
+
546
+
547
+ # ----------------------------------------------------------------------
548
+ # Rounded corners (Canvas approximation)
549
+ # ----------------------------------------------------------------------
550
+ #
551
+ # Tk widgets are rectangles and can't be clipped, so Frame-based views
552
+ # with a border radius get a full-size Canvas child that paints the
553
+ # background (and a uniform border outline) as a rounded polygon. The
554
+ # corner cutouts can't be transparent; they're filled with the logical
555
+ # parent's background color, which reads correctly whenever the parent
556
+ # paints a solid color behind the view. Content leaves (Text, Button,
557
+ # Image) skip the approximation entirely: a covering canvas would hide
558
+ # what they draw.
559
+
560
+ _RADIUS_ARC_STEPS = 8
561
+
562
+ _SIDE_BORDER_WIDTH_KEYS = (
563
+ "border_left_width",
564
+ "border_top_width",
565
+ "border_right_width",
566
+ "border_bottom_width",
567
+ )
568
+
569
+
570
+ def _corner_radii(props: Dict[str, Any]) -> Tuple[float, float, float, float]:
571
+ """Resolve ``(top-left, top-right, bottom-right, bottom-left)`` radii.
572
+
573
+ Per-corner keys fall back to ``border_radius``; missing values are
574
+ 0. Negative or unparseable values clamp to 0.
575
+ """
576
+ base = max(0.0, _finite(props.get("border_radius")))
577
+
578
+ def _one(key: str) -> float:
579
+ value = props.get(key)
580
+ if value is None:
581
+ return base
582
+ return max(0.0, _finite(value, base))
583
+
584
+ return (
585
+ _one("border_top_left_radius"),
586
+ _one("border_top_right_radius"),
587
+ _one("border_bottom_right_radius"),
588
+ _one("border_bottom_left_radius"),
589
+ )
590
+
591
+
592
+ def _resolve_border(props: Dict[str, Any]) -> Tuple[float, str]:
593
+ """Resolve the uniform ``(width, color)`` border approximation.
594
+
595
+ Tk borders are uniform, so per-side borders collapse to the widest
596
+ side's width and the first nonzero side's color.
597
+ """
598
+ width = props.get("border_width")
599
+ color = _tk_color(props.get("border_color")) or "#3c3c43"
600
+ side_widths = [props.get(k) for k in _SIDE_BORDER_WIDTH_KEYS]
601
+ if any(w is not None for w in side_widths):
602
+ width = max(float(w) for w in side_widths if w is not None)
603
+ for side, w in zip(("left", "top", "right", "bottom"), side_widths):
604
+ if w is not None and float(w) > 0:
605
+ side_color = _tk_color(props.get(f"border_{side}_color"))
606
+ if side_color is not None:
607
+ color = side_color
608
+ break
609
+ return (max(0.0, _finite(width)), color)
610
+
611
+
612
+ def _underlay_color(widget: Any) -> str:
613
+ """Best-effort color of whatever paints behind ``widget``.
614
+
615
+ Used to fill the corner cutouts of a rounded view: the logical
616
+ parent's rounded fill when it has one, else its plain Tk
617
+ background. A solid guess is the documented limitation; gradients
618
+ or images behind a rounded view can't be matched.
619
+ """
620
+ parent = getattr(widget, "_pn_parent", None)
621
+ target = parent if parent is not None else get_root_container()
622
+ if target is not None:
623
+ state = getattr(target, "_pn_radius_state", None)
624
+ if state is not None and state.get("fill"):
625
+ return str(state["fill"])
626
+ try:
627
+ return str(target.cget("background"))
628
+ except Exception:
629
+ pass
630
+ return "#ffffff"
631
+
632
+
633
+ def _rounded_rect_points(w: float, h: float, radii: Tuple[float, float, float, float]) -> List[float]:
634
+ """Vertices of a rounded rectangle, corner arcs sampled clockwise.
635
+
636
+ Radii are clamped proportionally so adjacent corners never
637
+ overlap. A zero radius degenerates to the plain corner point.
638
+ """
639
+ tl, tr, br, bl = radii
640
+ scale = min(
641
+ 1.0,
642
+ w / max(1e-6, tl + tr),
643
+ w / max(1e-6, bl + br),
644
+ h / max(1e-6, tl + bl),
645
+ h / max(1e-6, tr + br),
646
+ )
647
+ tl, tr, br, bl = (r * scale for r in (tl, tr, br, bl))
648
+ points: List[float] = []
649
+
650
+ def _arc(cx: float, cy: float, r: float, start_deg: float) -> None:
651
+ if r <= 0:
652
+ points.extend((cx, cy))
653
+ return
654
+ for step in range(_RADIUS_ARC_STEPS + 1):
655
+ a = math.radians(start_deg - 90.0 * step / _RADIUS_ARC_STEPS)
656
+ points.extend((cx + r * math.cos(a), cy - r * math.sin(a)))
657
+
658
+ _arc(tl, tl, tl, 180.0)
659
+ _arc(w - tr, tr, tr, 90.0)
660
+ _arc(w - br, h - br, br, 0.0)
661
+ _arc(bl, h - bl, bl, -90.0)
662
+ return points
663
+
664
+
665
+ def _redraw_rounded_background(widget: Any) -> None:
666
+ """Repaint the rounded background canvas if its inputs changed.
667
+
668
+ Sizes prefer the layout frame (kept current by ``_place``) over
669
+ ``winfo_*`` so the polygon is right even before Tk maps the
670
+ window. Redraws are skipped when the (size, colors, radii,
671
+ underlay) signature matches the last paint.
672
+ """
673
+ canvas = getattr(widget, "_pn_radius_canvas", None)
674
+ state = getattr(widget, "_pn_radius_state", None)
675
+ if canvas is None or state is None:
676
+ return
677
+ frame = getattr(widget, "_pn_frame", None)
678
+ if frame is not None and frame[2] > 0 and frame[3] > 0:
679
+ w, h = frame[2], frame[3]
680
+ else:
681
+ try:
682
+ w, h = float(canvas.winfo_width()), float(canvas.winfo_height())
683
+ except Exception:
684
+ return
685
+ if w <= 1 or h <= 1:
686
+ return
687
+ under = _underlay_color(widget)
688
+ signature = (w, h, state["radii"], state["fill"], state["outline"], state["outline_width"], under)
689
+ if state.get("drawn") == signature:
690
+ return
691
+ state["drawn"] = signature
692
+ try:
693
+ canvas.configure(background=under)
694
+ canvas.delete("all")
695
+ if state["fill"] or state["outline"]:
696
+ canvas.create_polygon(
697
+ _rounded_rect_points(w, h, state["radii"]),
698
+ fill=state["fill"] or "",
699
+ outline=state["outline"],
700
+ width=max(1.0, state["outline_width"]),
701
+ )
702
+ except Exception:
703
+ diagnostics.swallowed("desktop._redraw_rounded_background")
704
+
705
+
706
+ def _set_rounded_background(widget: Any, radii: Tuple[float, float, float, float], props: Dict[str, Any]) -> None:
707
+ """Install (or refresh) the rounded background canvas on ``widget``."""
708
+ border_width, border_color = _resolve_border(props)
709
+ widget._pn_radius_state = {
710
+ "radii": radii,
711
+ "fill": _tk_color(props.get("background_color")),
712
+ "outline": border_color if border_width else "",
713
+ "outline_width": border_width,
714
+ }
715
+ canvas = getattr(widget, "_pn_radius_canvas", None)
716
+ if canvas is None:
717
+ try:
718
+ canvas = tk.Canvas(widget, highlightthickness=0, bd=0)
719
+ canvas.place(x=0, y=0, relwidth=1.0, relheight=1.0)
720
+ # Below any direct Tk children the handler owns (TabBar /
721
+ # SegmentedControl buttons); PythonNative children live on
722
+ # the stage and are lifted above the whole frame anyway.
723
+ # ``Canvas.lower`` is the canvas-item method, so reach for
724
+ # the widget-stacking ``Misc.lower`` explicitly.
725
+ tk.Misc.lower(canvas)
726
+ # Clicks over the frame now land on the covering canvas,
727
+ # so append the frame's bindtag: its gesture and press
728
+ # bindings keep firing, with matching coordinates (the
729
+ # canvas fills the frame exactly).
730
+ canvas.bindtags(tuple(canvas.bindtags()) + (str(widget),))
731
+ canvas.bind("<Configure>", lambda _e: _redraw_rounded_background(widget), add="+")
732
+ except Exception:
733
+ diagnostics.swallowed("desktop._set_rounded_background")
734
+ return
735
+ canvas._pn_parent = widget # wheel hit-testing walks through
736
+ widget._pn_radius_canvas = canvas
737
+ _set_pointer_muted(canvas, _pointer_muted(widget))
738
+ _redraw_rounded_background(widget)
739
+
740
+
741
+ def _clear_rounded_background(widget: Any) -> None:
742
+ """Drop the rounded background canvas when radii return to zero."""
743
+ canvas = getattr(widget, "_pn_radius_canvas", None)
744
+ if canvas is None:
745
+ return
746
+ widget._pn_radius_canvas = None
747
+ widget._pn_radius_state = None
748
+ try:
749
+ canvas.destroy()
750
+ except Exception:
751
+ diagnostics.swallowed("desktop._clear_rounded_background")
752
+
753
+
349
754
  def _set_translate_from_transform(widget: Any, spec: Any) -> None:
350
755
  """Extract a translate offset from a ``transform`` prop for placement.
351
756
 
@@ -369,44 +774,40 @@ def _set_translate_from_transform(widget: Any, spec: Any) -> None:
369
774
 
370
775
  def _apply_common(widget: Any, props: Dict[str, Any]) -> None:
371
776
  """Apply visual props shared across most handlers (bg, border, transform)."""
372
- if "background_color" in props:
777
+ radii = _corner_radii(props) if isinstance(widget, tk.Frame) else (0.0, 0.0, 0.0, 0.0)
778
+ rounded = any(r > 0 for r in radii)
779
+ if "background_color" in props and not rounded:
373
780
  color = _tk_color(props["background_color"])
374
781
  if color is not None:
375
782
  try:
376
783
  widget.configure(background=color)
377
784
  except Exception:
378
- pass
379
- side_width_keys = (
380
- "border_left_width",
381
- "border_top_width",
382
- "border_right_width",
383
- "border_bottom_width",
384
- )
385
- if any(k in props for k in ("border_width", "border_color", *side_width_keys)):
785
+ diagnostics.swallowed("desktop._apply_common")
786
+ if rounded:
787
+ # Background and border move onto the rounded canvas; the
788
+ # square highlight border would poke out of the corners.
386
789
  try:
387
- width = props.get("border_width")
388
- color = _tk_color(props.get("border_color")) or "#3c3c43"
389
- # Tk highlight borders are uniform, so per-side borders are
390
- # approximated with the widest side's width and its color.
391
- side_widths = [props.get(k) for k in side_width_keys]
392
- if any(w is not None for w in side_widths):
393
- width = max(float(w) for w in side_widths if w is not None)
394
- for side, w in zip(("left", "top", "right", "bottom"), side_widths):
395
- if w is not None and float(w) > 0:
396
- side_color = _tk_color(props.get(f"border_{side}_color"))
397
- if side_color is not None:
398
- color = side_color
399
- break
400
- if width:
401
- widget.configure(
402
- highlightthickness=int(round(_finite(width))),
403
- highlightbackground=color,
404
- highlightcolor=color,
405
- )
406
- else:
407
- widget.configure(highlightthickness=0)
790
+ widget.configure(highlightthickness=0)
408
791
  except Exception:
409
- pass
792
+ diagnostics.swallowed("desktop._apply_common")
793
+ _set_rounded_background(widget, radii, props)
794
+ else:
795
+ _clear_rounded_background(widget)
796
+ if any(k in props for k in ("border_width", "border_color", *_SIDE_BORDER_WIDTH_KEYS)):
797
+ try:
798
+ width, color = _resolve_border(props)
799
+ if width:
800
+ widget.configure(
801
+ highlightthickness=int(round(width)),
802
+ highlightbackground=color,
803
+ highlightcolor=color,
804
+ )
805
+ else:
806
+ widget.configure(highlightthickness=0)
807
+ except Exception:
808
+ diagnostics.swallowed("desktop._apply_common")
809
+ if "hit_slop" in props:
810
+ widget._pn_hit_slop = _parse_hit_slop(props.get("hit_slop"))
410
811
  if "test_id" in props and props["test_id"] is not None:
411
812
  # Stamped for introspection so preview-level tooling and tests
412
813
  # can locate widgets the same way Maestro does on device.
@@ -414,6 +815,13 @@ def _apply_common(widget: Any, props: Dict[str, Any]) -> None:
414
815
  if "transform" in props:
415
816
  _set_translate_from_transform(widget, props["transform"])
416
817
  _place(widget)
818
+ if "z_index" in props:
819
+ parent = getattr(widget, "_pn_parent", None)
820
+ if parent is not None:
821
+ parent._pn_has_z = True
822
+ _restack(parent)
823
+ if "pointer_events" in props:
824
+ _refresh_pointer_events(widget)
417
825
 
418
826
 
419
827
  # ======================================================================
@@ -460,7 +868,7 @@ def _wire_gestures(widget: Any, specs: Any) -> None:
460
868
  try:
461
869
  widget.after(delay_ms, _poll)
462
870
  except Exception:
463
- pass
871
+ diagnostics.swallowed("desktop._wire_gestures._schedule_poll")
464
872
 
465
873
  def _on_down(event: Any) -> None:
466
874
  current = getattr(widget, "_pn_arbiter", None)
@@ -483,7 +891,7 @@ def _wire_gestures(widget: Any, specs: Any) -> None:
483
891
  widget.bind("<B1-Motion>", _on_move, add="+")
484
892
  widget.bind("<ButtonRelease-1>", _on_up, add="+")
485
893
  except Exception:
486
- pass
894
+ diagnostics.swallowed("desktop._wire_gestures")
487
895
 
488
896
 
489
897
  # ======================================================================
@@ -533,11 +941,19 @@ def _install_wheel_bindings(container: Any) -> None:
533
941
  top.bind_all("<Button-5>", _on_wheel, add="+")
534
942
  _WHEEL_BOUND = True
535
943
  except Exception:
536
- pass
944
+ diagnostics.swallowed("desktop._install_wheel_bindings")
537
945
 
538
946
 
539
947
  def _content_extent(widget: Any) -> Tuple[float, float]:
540
- """Max (right, bottom) edge over the scroll container's children."""
948
+ """Max (right, bottom) edge over the scroll container's children.
949
+
950
+ Containers that window their children (``VirtualList``) publish
951
+ the full logical extent via ``_pn_content_extent`` so clamping
952
+ covers rows that aren't currently mounted.
953
+ """
954
+ override = getattr(widget, "_pn_content_extent", None)
955
+ if override is not None:
956
+ return override
541
957
  max_x = 0.0
542
958
  max_y = 0.0
543
959
  for child in getattr(widget, "_pn_children", []) or []:
@@ -564,7 +980,12 @@ def _scroll_to(widget: Any, x: float, y: float, fire_event: bool = True) -> None
564
980
  widget._pn_scroll_offset = new_offset
565
981
  for child in getattr(widget, "_pn_children", []) or []:
566
982
  _place(child)
567
- if fire_event:
983
+ hook = getattr(widget, "_pn_scroll_hook", None)
984
+ if hook is not None:
985
+ # ``VirtualList`` re-windows its rows and reports the richer
986
+ # native-list scroll payload instead of the default event.
987
+ hook(new_offset, fire_event)
988
+ elif fire_event:
568
989
  _fire(widget, "on_scroll", {"x": new_offset[0], "y": new_offset[1]})
569
990
 
570
991
 
@@ -621,13 +1042,22 @@ class DesktopViewHandler(ViewHandler):
621
1042
  child._pn_parent = parent
622
1043
  _register_child(parent, child, index)
623
1044
  _place(child)
1045
+ # ``z_index`` applies before insertion (create-time props run
1046
+ # first), so stacking and pointer muting settle here, once the
1047
+ # parent chain is known.
1048
+ merged = getattr(child, "_pn_props", None) or {}
1049
+ if merged.get("z_index") is not None:
1050
+ parent._pn_has_z = True
1051
+ if getattr(parent, "_pn_has_z", False):
1052
+ _restack(parent)
1053
+ _refresh_pointer_events(child)
624
1054
 
625
1055
  def remove_child(self, parent: Any, child: Any) -> None:
626
1056
  _unregister_child(parent, child)
627
1057
  try:
628
1058
  child.place_forget()
629
1059
  except Exception:
630
- pass
1060
+ diagnostics.swallowed("desktop.DesktopViewHandler.remove_child")
631
1061
  child._pn_parent = None
632
1062
 
633
1063
  def destroy(self, native_view: Any) -> None:
@@ -638,7 +1068,7 @@ class DesktopViewHandler(ViewHandler):
638
1068
  try:
639
1069
  native_view.destroy()
640
1070
  except Exception:
641
- pass
1071
+ diagnostics.swallowed("desktop.DesktopViewHandler.destroy")
642
1072
 
643
1073
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
644
1074
  if native_view is None:
@@ -652,9 +1082,14 @@ class DesktopViewHandler(ViewHandler):
652
1082
  def set_animated_property(self, native_view: Any, prop_name: str, value: Any) -> None:
653
1083
  """Apply one frame of a Python-driven animation.
654
1084
 
655
- Translation maps onto placement; background color maps onto
656
- ``configure``. Opacity, scale, and rotation have no cheap Tk
657
- analogue and are skipped (a documented preview limitation).
1085
+ Translation maps onto placement; ``background_color`` and
1086
+ ``color`` map onto ``configure`` and accept every color form
1087
+ ``_tk_color`` understands, including the ``"#AARRGGBB"``
1088
+ strings that color interpolations emit (alpha is dropped).
1089
+ Opacity, scale, and rotation (numeric degrees) have no cheap
1090
+ Tk analogue, and other animated style keys have no desktop
1091
+ mapping; those frames are silently skipped (a documented
1092
+ preview limitation).
658
1093
  """
659
1094
  if native_view is None:
660
1095
  return
@@ -669,10 +1104,24 @@ class DesktopViewHandler(ViewHandler):
669
1104
  elif prop_name == "background_color":
670
1105
  color = _tk_color(value)
671
1106
  if color is not None:
1107
+ state = getattr(native_view, "_pn_radius_state", None)
1108
+ if state is not None:
1109
+ # A rounded view paints its background on the
1110
+ # radius canvas, not the frame itself.
1111
+ state["fill"] = color
1112
+ _redraw_rounded_background(native_view)
1113
+ return
672
1114
  try:
673
1115
  native_view.configure(background=color)
674
1116
  except Exception:
675
- pass
1117
+ diagnostics.swallowed("desktop.DesktopViewHandler.set_animated_property")
1118
+ elif prop_name == "color":
1119
+ color = _tk_color(value)
1120
+ if color is not None:
1121
+ try:
1122
+ native_view.configure(foreground=color)
1123
+ except Exception:
1124
+ diagnostics.swallowed("desktop.DesktopViewHandler.set_animated_property")
676
1125
 
677
1126
 
678
1127
  # ======================================================================
@@ -742,6 +1191,257 @@ class ScrollViewHandler(FlexContainerHandler):
742
1191
  _scroll_to(native_view, sx, sy, fire_event=False)
743
1192
 
744
1193
 
1194
+ # ======================================================================
1195
+ # VirtualList (natively virtualized list preview)
1196
+ # ======================================================================
1197
+
1198
+
1199
+ class VirtualListHandler(ScrollViewHandler):
1200
+ """Preview twin of the native ``VirtualList`` handlers.
1201
+
1202
+ Android backs ``VirtualList`` with a ``RecyclerView`` and iOS with
1203
+ a ``UITableView``; the desktop preview reuses the ScrollView
1204
+ machinery and does its own row windowing so trees that target the
1205
+ native-list contract behave the same way here. Rows within one
1206
+ viewport of the scroll window host a live subtree driven by a
1207
+ nested reconciler (see ``pythonnative.virtual_rows``); rows that
1208
+ leave the window are unmounted.
1209
+
1210
+ Expects props:
1211
+
1212
+ - ``count``: total number of rows.
1213
+ - ``row_height``: uniform row extent in points, or
1214
+ - ``row_heights``: per-row extents.
1215
+ - ``render_row``: ``render_row(index) -> Element`` producing one
1216
+ row's subtree. Called lazily as rows enter the window.
1217
+ - ``shows_scroll_indicator``: accepted and ignored (the preview
1218
+ draws no scroll bar).
1219
+
1220
+ Events (dispatched by tag): ``on_scroll`` with
1221
+ ``{"x", "y", "extent", "range"}`` in points, matching the device
1222
+ handlers. ``on_row_press`` is not synthesized: preview rows are
1223
+ live widgets, so presses land on the row's own Pressable / Button
1224
+ children.
1225
+
1226
+ Commands: ``scroll_to_offset`` / ``scroll_to_index`` /
1227
+ ``scroll_to_end`` / ``get_scroll_offset``. The ``animated`` flag
1228
+ is accepted and ignored (the preview jumps, like ScrollView).
1229
+ """
1230
+
1231
+ def build(self, props: Dict[str, Any]) -> Any:
1232
+ from ..virtual_rows import RowHostPool
1233
+
1234
+ frame = super().build(props)
1235
+ frame._pn_vl = {
1236
+ "count": 0,
1237
+ "row_height": 44.0,
1238
+ "row_heights": None,
1239
+ "render_row": None,
1240
+ "starts": [0.0],
1241
+ "pool": RowHostPool(),
1242
+ "cells": {},
1243
+ "cell_width": 0.0,
1244
+ }
1245
+ frame._pn_content_extent = (0.0, 0.0)
1246
+
1247
+ def _hook(offset: Tuple[float, float], fire_event: bool) -> None:
1248
+ self._rewindow(frame)
1249
+ if fire_event:
1250
+ viewport = getattr(frame, "_pn_frame", None)
1251
+ _fire(
1252
+ frame,
1253
+ "on_scroll",
1254
+ {
1255
+ "x": 0.0,
1256
+ "y": offset[1],
1257
+ "extent": viewport[3] if viewport else 0.0,
1258
+ "range": frame._pn_vl["starts"][-1],
1259
+ },
1260
+ )
1261
+
1262
+ frame._pn_scroll_hook = _hook
1263
+ return frame
1264
+
1265
+ def apply(self, frame: Any, props: Dict[str, Any]) -> None:
1266
+ super().apply(frame, props)
1267
+ layout_changed, content_changed = self._read_data_props(frame, props)
1268
+ if layout_changed:
1269
+ # Geometry changed under the mounted window; rebuild it.
1270
+ self._release_rows(frame)
1271
+ self._rewindow(frame)
1272
+ elif content_changed:
1273
+ # Only ``render_row`` changed (a fresh closure every
1274
+ # render); reconcile the live rows in place, mirroring
1275
+ # the device handlers' reload-on-render behavior.
1276
+ self._rebind_rows(frame)
1277
+
1278
+ def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
1279
+ super().set_frame(native_view, x, y, width, height)
1280
+ self._rewindow(native_view)
1281
+
1282
+ def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
1283
+ # Fill the available space, like a ScrollView clamped to its
1284
+ # parent; collapse to 0 on unbounded axes (nested lists don't
1285
+ # scroll, matching the device handlers).
1286
+ w = max_width if math.isfinite(max_width) else 0.0
1287
+ h = max_height if math.isfinite(max_height) else 0.0
1288
+ return (max(0.0, w), max(0.0, h))
1289
+
1290
+ def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
1291
+ info = getattr(native_view, "_pn_vl", None)
1292
+ if info is None:
1293
+ return None
1294
+ if name == "scroll_to_offset":
1295
+ _scroll_to(native_view, 0.0, _finite(args.get("y", 0.0)))
1296
+ return True
1297
+ if name == "scroll_to_index":
1298
+ count = info["count"]
1299
+ index = max(0, min(int(_finite(args.get("index", 0))), max(0, count - 1)))
1300
+ _scroll_to(native_view, 0.0, info["starts"][index] if count else 0.0)
1301
+ return True
1302
+ if name == "scroll_to_end":
1303
+ _scroll_to(native_view, 0.0, info["starts"][-1])
1304
+ return True
1305
+ if name == "get_scroll_offset":
1306
+ sx, sy = getattr(native_view, "_pn_scroll_offset", (0.0, 0.0))
1307
+ return {"x": sx, "y": sy}
1308
+ return None
1309
+
1310
+ def destroy(self, native_view: Any) -> None:
1311
+ info = getattr(native_view, "_pn_vl", None)
1312
+ if info is not None:
1313
+ self._release_rows(native_view)
1314
+ try:
1315
+ info["pool"].release_all()
1316
+ except Exception:
1317
+ diagnostics.swallowed("desktop.VirtualListHandler.destroy")
1318
+ super().destroy(native_view)
1319
+
1320
+ # -- data + windowing ----------------------------------------------
1321
+
1322
+ def _read_data_props(self, frame: Any, props: Dict[str, Any]) -> Tuple[bool, bool]:
1323
+ """Fold changed data props into ``_pn_vl``.
1324
+
1325
+ Returns ``(layout_changed, content_changed)``: the first is
1326
+ true when row geometry changed (count or extents) and the
1327
+ window must be rebuilt; the second when ``render_row`` changed
1328
+ and live rows only need a rebind.
1329
+ """
1330
+ info = frame._pn_vl
1331
+ layout_changed = False
1332
+ if "count" in props:
1333
+ info["count"] = int(props.get("count") or 0)
1334
+ layout_changed = True
1335
+ if "row_height" in props and props.get("row_height") is not None:
1336
+ info["row_height"] = max(0.0, _finite(props["row_height"], 44.0))
1337
+ layout_changed = True
1338
+ if "row_heights" in props:
1339
+ heights = props.get("row_heights")
1340
+ info["row_heights"] = [max(0.0, _finite(h)) for h in heights] if heights else None
1341
+ layout_changed = True
1342
+ content_changed = False
1343
+ if "render_row" in props:
1344
+ info["render_row"] = props.get("render_row")
1345
+ content_changed = True
1346
+ if layout_changed:
1347
+ n = info["count"]
1348
+ heights = info["row_heights"]
1349
+ starts = [0.0] * (n + 1)
1350
+ acc = 0.0
1351
+ for i in range(n):
1352
+ starts[i] = acc
1353
+ extent = heights[i] if heights is not None and i < len(heights) else info["row_height"]
1354
+ acc += max(0.0, extent)
1355
+ starts[n] = acc
1356
+ info["starts"] = starts
1357
+ # Publish the full content extent so ``_scroll_to`` clamps
1358
+ # against every row, not just the mounted window.
1359
+ frame._pn_content_extent = (0.0, acc)
1360
+ return (layout_changed, content_changed)
1361
+
1362
+ def _rewindow(self, frame: Any) -> None:
1363
+ """Mount rows within one viewport of the window, unmount the rest."""
1364
+ info = getattr(frame, "_pn_vl", None)
1365
+ if info is None or info["render_row"] is None:
1366
+ return
1367
+ viewport = getattr(frame, "_pn_frame", None)
1368
+ if viewport is None:
1369
+ return
1370
+ n = info["count"]
1371
+ width = max(0.0, viewport[2])
1372
+ height = max(0.0, viewport[3])
1373
+ if n <= 0 or width <= 0 or height <= 0:
1374
+ self._release_rows(frame)
1375
+ return
1376
+ if width != info["cell_width"]:
1377
+ # Mounted rows were laid out against a different width.
1378
+ self._release_rows(frame)
1379
+ info["cell_width"] = width
1380
+ starts = info["starts"]
1381
+ offset = getattr(frame, "_pn_scroll_offset", (0.0, 0.0))[1]
1382
+ lo = max(0.0, offset - height)
1383
+ hi = offset + 2.0 * height
1384
+ first = max(0, bisect.bisect_right(starts, lo, 0, n) - 1)
1385
+ last = min(n - 1, bisect.bisect_left(starts, hi, 0, n))
1386
+ cells: Dict[int, Any] = info["cells"]
1387
+ for index in [i for i in cells if i < first or i > last]:
1388
+ self._release_row(frame, index)
1389
+ for index in range(first, last + 1):
1390
+ if index in cells:
1391
+ continue
1392
+ cell = tk.Frame(_master(), highlightthickness=0, bd=0)
1393
+ cell._pn_parent = frame
1394
+ cells[index] = cell
1395
+ _register_child(frame, cell, len(getattr(frame, "_pn_children", None) or []))
1396
+ cell._pn_frame = (0.0, starts[index], width, starts[index + 1] - starts[index])
1397
+ _place(cell)
1398
+ self._attach_row(frame, cell, index)
1399
+
1400
+ def _attach_row(self, frame: Any, cell: Any, index: int) -> None:
1401
+ """Mount (or rebind) row ``index`` into ``cell`` and place its root."""
1402
+ info = frame._pn_vl
1403
+ render_row = info["render_row"]
1404
+ cell_frame = getattr(cell, "_pn_frame", (0.0, 0.0, 0.0, 0.0))
1405
+ try:
1406
+ root = info["pool"].bind(index, lambda: render_row(index), cell_frame[2], cell_frame[3])
1407
+ except Exception:
1408
+ diagnostics.swallowed("desktop.VirtualListHandler._attach_row")
1409
+ return
1410
+ if root is None:
1411
+ return
1412
+ # A rebind can replace the subtree's root, so reset the cell's
1413
+ # child list instead of accumulating stale widgets.
1414
+ root._pn_parent = cell
1415
+ cell._pn_children = [root]
1416
+ _place(root)
1417
+
1418
+ def _rebind_rows(self, frame: Any) -> None:
1419
+ info = frame._pn_vl
1420
+ for index, cell in list(info["cells"].items()):
1421
+ self._attach_row(frame, cell, index)
1422
+
1423
+ def _release_row(self, frame: Any, index: int) -> None:
1424
+ info = frame._pn_vl
1425
+ cell = info["cells"].pop(index, None)
1426
+ try:
1427
+ info["pool"].release(index)
1428
+ except Exception:
1429
+ diagnostics.swallowed("desktop.VirtualListHandler._release_row")
1430
+ if cell is not None:
1431
+ _unregister_child(frame, cell)
1432
+ try:
1433
+ cell.destroy()
1434
+ except Exception:
1435
+ diagnostics.swallowed("desktop.VirtualListHandler._release_row")
1436
+
1437
+ def _release_rows(self, frame: Any) -> None:
1438
+ info = getattr(frame, "_pn_vl", None)
1439
+ if info is None:
1440
+ return
1441
+ for index in list(info["cells"]):
1442
+ self._release_row(frame, index)
1443
+
1444
+
745
1445
  # ======================================================================
746
1446
  # Text
747
1447
  # ======================================================================
@@ -775,7 +1475,7 @@ class TextHandler(DesktopViewHandler):
775
1475
  try:
776
1476
  label.configure(**opts)
777
1477
  except Exception:
778
- pass
1478
+ diagnostics.swallowed("desktop.TextHandler.apply")
779
1479
  _apply_common(label, merged)
780
1480
 
781
1481
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
@@ -784,7 +1484,7 @@ class TextHandler(DesktopViewHandler):
784
1484
  try:
785
1485
  native_view.configure(wraplength=max(1, int(_finite(width))))
786
1486
  except Exception:
787
- pass
1487
+ diagnostics.swallowed("desktop.TextHandler.set_frame")
788
1488
  super().set_frame(native_view, x, y, width, height)
789
1489
 
790
1490
  def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
@@ -825,7 +1525,7 @@ class ButtonHandler(DesktopViewHandler):
825
1525
  try:
826
1526
  button.configure(**opts)
827
1527
  except Exception:
828
- pass
1528
+ diagnostics.swallowed("desktop.ButtonHandler.apply")
829
1529
  _apply_common(button, merged)
830
1530
 
831
1531
  def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
@@ -873,7 +1573,7 @@ class TextInputHandler(DesktopViewHandler):
873
1573
  widget.delete(0, "end")
874
1574
  widget.insert(0, value)
875
1575
  except Exception:
876
- pass
1576
+ diagnostics.swallowed("desktop.TextInputHandler._set_text")
877
1577
  finally:
878
1578
  widget._pn_suppress = False
879
1579
 
@@ -900,7 +1600,7 @@ class TextInputHandler(DesktopViewHandler):
900
1600
  if not getattr(widget, "_pn_multiline", False):
901
1601
  widget.bind("<Return>", _on_return)
902
1602
  except Exception:
903
- pass
1603
+ diagnostics.swallowed("desktop.TextInputHandler._bind")
904
1604
 
905
1605
  def apply(self, widget: Any, props: Dict[str, Any]) -> None:
906
1606
  merged = getattr(widget, "_pn_props", props)
@@ -918,7 +1618,7 @@ class TextInputHandler(DesktopViewHandler):
918
1618
  try:
919
1619
  widget.configure(**opts)
920
1620
  except Exception:
921
- pass
1621
+ diagnostics.swallowed("desktop.TextInputHandler.apply")
922
1622
  if "value" in props:
923
1623
  incoming = "" if props["value"] is None else str(props["value"])
924
1624
  if self._current_text(widget) != incoming:
@@ -927,20 +1627,20 @@ class TextInputHandler(DesktopViewHandler):
927
1627
  try:
928
1628
  widget.configure(highlightbackground="#c7c7cc", highlightcolor="#007aff")
929
1629
  except Exception:
930
- pass
1630
+ diagnostics.swallowed("desktop.TextInputHandler.apply")
931
1631
 
932
1632
  def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
933
1633
  if name == "focus":
934
1634
  try:
935
1635
  native_view.focus_set()
936
1636
  except Exception:
937
- pass
1637
+ diagnostics.swallowed("desktop.TextInputHandler.command")
938
1638
  return True
939
1639
  if name == "blur":
940
1640
  try:
941
1641
  native_view.winfo_toplevel().focus_set()
942
1642
  except Exception:
943
- pass
1643
+ diagnostics.swallowed("desktop.TextInputHandler.command")
944
1644
  return True
945
1645
  return None
946
1646
 
@@ -982,7 +1682,7 @@ class ImageHandler(DesktopViewHandler):
982
1682
  try:
983
1683
  label.configure(background=color)
984
1684
  except Exception:
985
- pass
1685
+ diagnostics.swallowed("desktop.ImageHandler.apply")
986
1686
  if "source" in props:
987
1687
  source = props.get("source")
988
1688
  label._pn_pending_source = source
@@ -1021,14 +1721,14 @@ class ImageHandler(DesktopViewHandler):
1021
1721
  if path:
1022
1722
  _fire(label, "on_error", "decode failed")
1023
1723
  except Exception:
1024
- pass
1724
+ diagnostics.swallowed("desktop.ImageHandler._show_file")
1025
1725
 
1026
1726
  def _show_fallback(self, label: Any, source: Any) -> None:
1027
1727
  name = str(source).rsplit("/", 1)[-1] if source else "image"
1028
1728
  try:
1029
1729
  label.configure(image="", text=f"\U0001f5bc\n{name}", compound="center")
1030
1730
  except Exception:
1031
- pass
1731
+ diagnostics.swallowed("desktop.ImageHandler._show_fallback")
1032
1732
 
1033
1733
  def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
1034
1734
  photo = getattr(native_view, "_pn_photo", None)
@@ -1036,7 +1736,7 @@ class ImageHandler(DesktopViewHandler):
1036
1736
  try:
1037
1737
  return (float(photo.width()), float(photo.height()))
1038
1738
  except Exception:
1039
- pass
1739
+ diagnostics.swallowed("desktop.ImageHandler.measure_intrinsic")
1040
1740
  return (64.0, 64.0)
1041
1741
 
1042
1742
 
@@ -1059,7 +1759,7 @@ class SwitchHandler(DesktopViewHandler):
1059
1759
  try:
1060
1760
  check._pn_var.set(1 if props.get("value") else 0)
1061
1761
  except Exception:
1062
- pass
1762
+ diagnostics.swallowed("desktop.SwitchHandler.apply")
1063
1763
  _apply_common(check, merged)
1064
1764
 
1065
1765
  def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
@@ -1088,12 +1788,12 @@ class CheckboxHandler(DesktopViewHandler):
1088
1788
  try:
1089
1789
  check.configure(**opts)
1090
1790
  except Exception:
1091
- pass
1791
+ diagnostics.swallowed("desktop.CheckboxHandler.apply")
1092
1792
  if "value" in props:
1093
1793
  try:
1094
1794
  check._pn_var.set(1 if props.get("value") else 0)
1095
1795
  except Exception:
1096
- pass
1796
+ diagnostics.swallowed("desktop.CheckboxHandler.apply")
1097
1797
  _apply_common(check, merged)
1098
1798
 
1099
1799
 
@@ -1118,7 +1818,7 @@ class SliderHandler(DesktopViewHandler):
1118
1818
  try:
1119
1819
  _fire(scale, "on_change", float(scale.get()))
1120
1820
  except Exception:
1121
- pass
1821
+ diagnostics.swallowed("desktop.SliderHandler.build._command")
1122
1822
 
1123
1823
  scale.configure(command=_command)
1124
1824
  return scale
@@ -1134,13 +1834,13 @@ class SliderHandler(DesktopViewHandler):
1134
1834
  try:
1135
1835
  scale.configure(**opts)
1136
1836
  except Exception:
1137
- pass
1837
+ diagnostics.swallowed("desktop.SliderHandler.apply")
1138
1838
  if "value" in merged:
1139
1839
  scale._pn_suppress = True
1140
1840
  try:
1141
1841
  scale.set(_finite(merged.get("value")))
1142
1842
  except Exception:
1143
- pass
1843
+ diagnostics.swallowed("desktop.SliderHandler.apply")
1144
1844
  finally:
1145
1845
  scale._pn_suppress = False
1146
1846
  _apply_common(scale, merged)
@@ -1161,12 +1861,12 @@ class ProgressBarHandler(DesktopViewHandler):
1161
1861
  bar.configure(mode="indeterminate")
1162
1862
  bar.start(60)
1163
1863
  except Exception:
1164
- pass
1864
+ diagnostics.swallowed("desktop.ProgressBarHandler.apply")
1165
1865
  else:
1166
1866
  try:
1167
1867
  bar.configure(mode="determinate", value=max(0.0, min(1.0, _finite(merged.get("value", 0.0)))))
1168
1868
  except Exception:
1169
- pass
1869
+ diagnostics.swallowed("desktop.ProgressBarHandler.apply")
1170
1870
 
1171
1871
  def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
1172
1872
  width = max_width if math.isfinite(max_width) else 200.0
@@ -1185,7 +1885,7 @@ class ActivityIndicatorHandler(DesktopViewHandler):
1185
1885
  else:
1186
1886
  bar.stop()
1187
1887
  except Exception:
1188
- pass
1888
+ diagnostics.swallowed("desktop.ActivityIndicatorHandler.apply")
1189
1889
 
1190
1890
  def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
1191
1891
  merged = getattr(native_view, "_pn_props", {}) or {}
@@ -1228,7 +1928,7 @@ class WebViewHandler(DesktopViewHandler):
1228
1928
  try:
1229
1929
  label.configure(text=f"\U0001f310 WebView\n{target}")
1230
1930
  except Exception:
1231
- pass
1931
+ diagnostics.swallowed("desktop.WebViewHandler.apply")
1232
1932
  _apply_common(label, merged)
1233
1933
 
1234
1934
 
@@ -1238,7 +1938,16 @@ class WebViewHandler(DesktopViewHandler):
1238
1938
 
1239
1939
 
1240
1940
  class PressableHandler(DesktopViewHandler):
1241
- """A frame that forwards press / long-press / gestures."""
1941
+ """A frame that forwards press / long-press / gestures.
1942
+
1943
+ ``hit_slop`` support is partial (see ``_within_hit_rect``): the
1944
+ slop insets can't grow the initial press target, but they expand
1945
+ the tracking rect, so a release (or pointer drift during a pending
1946
+ long press) within the slop of the frame's edges still counts as
1947
+ inside. A release outside the expanded rect fires ``on_press_out``
1948
+ without ``on_press``, matching the device backends' cancel-on-exit
1949
+ behavior.
1950
+ """
1242
1951
 
1243
1952
  def build(self, props: Dict[str, Any]) -> Any:
1244
1953
  frame = tk.Frame(_master(), highlightthickness=0, bd=0, cursor="hand2")
@@ -1251,7 +1960,8 @@ class PressableHandler(DesktopViewHandler):
1251
1960
  frame._pn_long_fired = False
1252
1961
  self._cancel_long(frame)
1253
1962
  _fire(frame, "on_press_out")
1254
- if not fired_long:
1963
+ inside = event is None or _within_hit_rect(frame, float(event.x), float(event.y))
1964
+ if not fired_long and inside:
1255
1965
  _fire(frame, "on_press")
1256
1966
 
1257
1967
  def _on_press_down(_event: Any = None) -> None:
@@ -1264,15 +1974,21 @@ class PressableHandler(DesktopViewHandler):
1264
1974
  frame._pn_long_fired = True
1265
1975
  _fire(frame, "on_long_press")
1266
1976
 
1267
- def _on_leave(_event: Any = None) -> None:
1268
- self._cancel_long(frame)
1977
+ def _on_drift(event: Any = None) -> None:
1978
+ # A pending long press survives pointer drift inside the
1979
+ # hit rect (frame plus hit_slop). <Leave> fires once at
1980
+ # the edge crossing, so <B1-Motion> covers travel beyond
1981
+ # it; Tk's implicit grab keeps reporting positions.
1982
+ if event is None or not _within_hit_rect(frame, float(event.x), float(event.y)):
1983
+ self._cancel_long(frame)
1269
1984
 
1270
1985
  try:
1271
1986
  frame.bind("<ButtonRelease-1>", _on_release, add="+")
1272
1987
  frame.bind("<ButtonPress-1>", _on_press_down, add="+")
1273
- frame.bind("<Leave>", _on_leave, add="+")
1988
+ frame.bind("<B1-Motion>", _on_drift, add="+")
1989
+ frame.bind("<Leave>", _on_drift, add="+")
1274
1990
  except Exception:
1275
- pass
1991
+ diagnostics.swallowed("desktop.PressableHandler._bind")
1276
1992
 
1277
1993
  @staticmethod
1278
1994
  def _cancel_long(frame: Any) -> None:
@@ -1281,7 +1997,7 @@ class PressableHandler(DesktopViewHandler):
1281
1997
  try:
1282
1998
  frame.after_cancel(after_id)
1283
1999
  except Exception:
1284
- pass
2000
+ diagnostics.swallowed("desktop.PressableHandler._cancel_long")
1285
2001
  frame._pn_long_after = None
1286
2002
 
1287
2003
 
@@ -1313,7 +2029,7 @@ class ModalHandler(DesktopViewHandler):
1313
2029
  else:
1314
2030
  frame.place_forget()
1315
2031
  except Exception:
1316
- pass
2032
+ diagnostics.swallowed("desktop.ModalHandler.apply")
1317
2033
  if visible != getattr(frame, "_pn_was_visible", None):
1318
2034
  frame._pn_was_visible = visible
1319
2035
  if visible:
@@ -1357,7 +2073,7 @@ class PortalHandler(DesktopViewHandler):
1357
2073
  try:
1358
2074
  child.place_forget()
1359
2075
  except Exception:
1360
- pass
2076
+ diagnostics.swallowed("desktop.PortalHandler.remove_child")
1361
2077
 
1362
2078
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
1363
2079
  # The portal frame itself stays unmapped; only children render.
@@ -1377,7 +2093,7 @@ class TabBarHandler(DesktopViewHandler):
1377
2093
  try:
1378
2094
  frame.configure(highlightbackground="#c6c6c8", highlightcolor="#c6c6c8")
1379
2095
  except Exception:
1380
- pass
2096
+ diagnostics.swallowed("desktop.TabBarHandler.build")
1381
2097
  frame._pn_buttons = []
1382
2098
  return frame
1383
2099
 
@@ -1389,7 +2105,7 @@ class TabBarHandler(DesktopViewHandler):
1389
2105
  try:
1390
2106
  button.destroy()
1391
2107
  except Exception:
1392
- pass
2108
+ diagnostics.swallowed("desktop.TabBarHandler.apply")
1393
2109
  buttons: List[Any] = []
1394
2110
  for item in items:
1395
2111
  name = item.get("name")
@@ -1428,7 +2144,7 @@ class TabBarHandler(DesktopViewHandler):
1428
2144
  try:
1429
2145
  button.place(x=i * each, y=0, width=each, height=frame_h)
1430
2146
  except Exception:
1431
- pass
2147
+ diagnostics.swallowed("desktop.TabBarHandler._layout_buttons")
1432
2148
 
1433
2149
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
1434
2150
  native_view._pn_size = (max(0.0, _finite(width)), max(0.0, _finite(height)))
@@ -1458,7 +2174,7 @@ class PickerHandler(DesktopViewHandler):
1458
2174
  try:
1459
2175
  combo.bind("<<ComboboxSelected>>", _on_select)
1460
2176
  except Exception:
1461
- pass
2177
+ diagnostics.swallowed("desktop.PickerHandler.build")
1462
2178
  return combo
1463
2179
 
1464
2180
  def apply(self, combo: Any, props: Dict[str, Any]) -> None:
@@ -1469,7 +2185,7 @@ class PickerHandler(DesktopViewHandler):
1469
2185
  try:
1470
2186
  combo.configure(values=labels)
1471
2187
  except Exception:
1472
- pass
2188
+ diagnostics.swallowed("desktop.PickerHandler.apply")
1473
2189
  if "value" in merged:
1474
2190
  target = merged.get("value")
1475
2191
  for i, item in enumerate(items):
@@ -1477,7 +2193,7 @@ class PickerHandler(DesktopViewHandler):
1477
2193
  try:
1478
2194
  combo.current(i)
1479
2195
  except Exception:
1480
- pass
2196
+ diagnostics.swallowed("desktop.PickerHandler.apply")
1481
2197
  break
1482
2198
 
1483
2199
  def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
@@ -1499,7 +2215,7 @@ class SegmentedControlHandler(DesktopViewHandler):
1499
2215
  try:
1500
2216
  button.destroy()
1501
2217
  except Exception:
1502
- pass
2218
+ diagnostics.swallowed("desktop.SegmentedControlHandler.apply")
1503
2219
  buttons: List[Any] = []
1504
2220
  for i, label in enumerate(segments):
1505
2221
  is_active = i == selected
@@ -1522,7 +2238,7 @@ class SegmentedControlHandler(DesktopViewHandler):
1522
2238
  state = "normal" if merged.get("enabled", True) else "disabled"
1523
2239
  button.configure({"highlightbackground": tint, "state": state})
1524
2240
  except Exception:
1525
- pass
2241
+ diagnostics.swallowed("desktop.SegmentedControlHandler.apply")
1526
2242
  buttons.append(button)
1527
2243
  frame._pn_buttons = buttons
1528
2244
  self._layout_buttons(frame)
@@ -1540,7 +2256,7 @@ class SegmentedControlHandler(DesktopViewHandler):
1540
2256
  try:
1541
2257
  button.place(x=i * each, y=0, width=each, height=frame_h)
1542
2258
  except Exception:
1543
- pass
2259
+ diagnostics.swallowed("desktop.SegmentedControlHandler._layout_buttons")
1544
2260
 
1545
2261
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
1546
2262
  native_view._pn_size = (max(0.0, _finite(width)), max(0.0, _finite(height)))
@@ -1560,7 +2276,7 @@ class DatePickerHandler(DesktopViewHandler):
1560
2276
  try:
1561
2277
  entry.bind("<KeyRelease>", _on_key)
1562
2278
  except Exception:
1563
- pass
2279
+ diagnostics.swallowed("desktop.DatePickerHandler.build")
1564
2280
  return entry
1565
2281
 
1566
2282
  def apply(self, entry: Any, props: Dict[str, Any]) -> None:
@@ -1569,7 +2285,7 @@ class DatePickerHandler(DesktopViewHandler):
1569
2285
  try:
1570
2286
  entry.configure(state="normal" if merged.get("enabled", True) else "disabled")
1571
2287
  except Exception:
1572
- pass
2288
+ diagnostics.swallowed("desktop.DatePickerHandler.apply")
1573
2289
  if "value" in props:
1574
2290
  incoming = "" if props["value"] is None else str(props["value"])
1575
2291
  try:
@@ -1580,11 +2296,11 @@ class DatePickerHandler(DesktopViewHandler):
1580
2296
  entry.insert(0, incoming)
1581
2297
  entry.configure(state=state)
1582
2298
  except Exception:
1583
- pass
2299
+ diagnostics.swallowed("desktop.DatePickerHandler.apply")
1584
2300
  try:
1585
2301
  entry.configure(highlightbackground="#c7c7cc", highlightcolor="#007aff")
1586
2302
  except Exception:
1587
- pass
2303
+ diagnostics.swallowed("desktop.DatePickerHandler.apply")
1588
2304
 
1589
2305
 
1590
2306
  # ======================================================================
@@ -1596,9 +2312,12 @@ def register_handlers(registry: Any) -> None:
1596
2312
  """Register every built-in desktop handler on ``registry``.
1597
2313
 
1598
2314
  Mirrors ``register_handlers`` in the iOS / Android backends so the
1599
- desktop registry services the same element types. Lists
1600
- (``FlatList`` / ``SectionList``) need no handler: they are Python
1601
- components that virtualize on top of ``ScrollView``.
2315
+ desktop registry services the same element types, including
2316
+ ``VirtualList``. ``FlatList`` and ``SectionList`` still pick the
2317
+ Python-windowed engine on desktop (``_native_lists_supported`` is
2318
+ false here), but trees that emit ``VirtualList`` directly, and
2319
+ tests that exercise the native-list routing, get the same command
2320
+ and prop contract in the preview.
1602
2321
  """
1603
2322
  flex = FlexContainerHandler()
1604
2323
  registry.register("View", flex)
@@ -1626,3 +2345,4 @@ def register_handlers(registry: Any) -> None:
1626
2345
  registry.register("Checkbox", CheckboxHandler())
1627
2346
  registry.register("SegmentedControl", SegmentedControlHandler())
1628
2347
  registry.register("DatePicker", DatePickerHandler())
2348
+ registry.register("VirtualList", VirtualListHandler())