pythonnative 0.26.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.
@@ -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
@@ -327,6 +358,16 @@ def _place(widget: Any) -> None:
327
358
  widget.lift()
328
359
  except Exception:
329
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)
330
371
 
331
372
 
332
373
  def _register_child(parent: Any, child: Any, index: int) -> None:
@@ -347,6 +388,369 @@ def _unregister_child(parent: Any, child: Any) -> None:
347
388
  parent._pn_children = children
348
389
 
349
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
+
350
754
  def _set_translate_from_transform(widget: Any, spec: Any) -> None:
351
755
  """Extract a translate offset from a ``transform`` prop for placement.
352
756
 
@@ -370,44 +774,40 @@ def _set_translate_from_transform(widget: Any, spec: Any) -> None:
370
774
 
371
775
  def _apply_common(widget: Any, props: Dict[str, Any]) -> None:
372
776
  """Apply visual props shared across most handlers (bg, border, transform)."""
373
- 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:
374
780
  color = _tk_color(props["background_color"])
375
781
  if color is not None:
376
782
  try:
377
783
  widget.configure(background=color)
378
784
  except Exception:
379
785
  diagnostics.swallowed("desktop._apply_common")
380
- side_width_keys = (
381
- "border_left_width",
382
- "border_top_width",
383
- "border_right_width",
384
- "border_bottom_width",
385
- )
386
- if any(k in props for k in ("border_width", "border_color", *side_width_keys)):
786
+ if rounded:
787
+ # Background and border move onto the rounded canvas; the
788
+ # square highlight border would poke out of the corners.
387
789
  try:
388
- width = props.get("border_width")
389
- color = _tk_color(props.get("border_color")) or "#3c3c43"
390
- # Tk highlight borders are uniform, so per-side borders are
391
- # approximated with the widest side's width and its color.
392
- side_widths = [props.get(k) for k in side_width_keys]
393
- if any(w is not None for w in side_widths):
394
- width = max(float(w) for w in side_widths if w is not None)
395
- for side, w in zip(("left", "top", "right", "bottom"), side_widths):
396
- if w is not None and float(w) > 0:
397
- side_color = _tk_color(props.get(f"border_{side}_color"))
398
- if side_color is not None:
399
- color = side_color
400
- break
401
- if width:
402
- widget.configure(
403
- highlightthickness=int(round(_finite(width))),
404
- highlightbackground=color,
405
- highlightcolor=color,
406
- )
407
- else:
408
- widget.configure(highlightthickness=0)
790
+ widget.configure(highlightthickness=0)
409
791
  except Exception:
410
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"))
411
811
  if "test_id" in props and props["test_id"] is not None:
412
812
  # Stamped for introspection so preview-level tooling and tests
413
813
  # can locate widgets the same way Maestro does on device.
@@ -415,6 +815,13 @@ def _apply_common(widget: Any, props: Dict[str, Any]) -> None:
415
815
  if "transform" in props:
416
816
  _set_translate_from_transform(widget, props["transform"])
417
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)
418
825
 
419
826
 
420
827
  # ======================================================================
@@ -538,7 +945,15 @@ def _install_wheel_bindings(container: Any) -> None:
538
945
 
539
946
 
540
947
  def _content_extent(widget: Any) -> Tuple[float, float]:
541
- """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
542
957
  max_x = 0.0
543
958
  max_y = 0.0
544
959
  for child in getattr(widget, "_pn_children", []) or []:
@@ -565,7 +980,12 @@ def _scroll_to(widget: Any, x: float, y: float, fire_event: bool = True) -> None
565
980
  widget._pn_scroll_offset = new_offset
566
981
  for child in getattr(widget, "_pn_children", []) or []:
567
982
  _place(child)
568
- 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:
569
989
  _fire(widget, "on_scroll", {"x": new_offset[0], "y": new_offset[1]})
570
990
 
571
991
 
@@ -622,6 +1042,15 @@ class DesktopViewHandler(ViewHandler):
622
1042
  child._pn_parent = parent
623
1043
  _register_child(parent, child, index)
624
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)
625
1054
 
626
1055
  def remove_child(self, parent: Any, child: Any) -> None:
627
1056
  _unregister_child(parent, child)
@@ -653,9 +1082,14 @@ class DesktopViewHandler(ViewHandler):
653
1082
  def set_animated_property(self, native_view: Any, prop_name: str, value: Any) -> None:
654
1083
  """Apply one frame of a Python-driven animation.
655
1084
 
656
- Translation maps onto placement; background color maps onto
657
- ``configure``. Opacity, scale, and rotation have no cheap Tk
658
- 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).
659
1093
  """
660
1094
  if native_view is None:
661
1095
  return
@@ -670,10 +1104,24 @@ class DesktopViewHandler(ViewHandler):
670
1104
  elif prop_name == "background_color":
671
1105
  color = _tk_color(value)
672
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
673
1114
  try:
674
1115
  native_view.configure(background=color)
675
1116
  except Exception:
676
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")
677
1125
 
678
1126
 
679
1127
  # ======================================================================
@@ -743,6 +1191,257 @@ class ScrollViewHandler(FlexContainerHandler):
743
1191
  _scroll_to(native_view, sx, sy, fire_event=False)
744
1192
 
745
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
+
746
1445
  # ======================================================================
747
1446
  # Text
748
1447
  # ======================================================================
@@ -1239,7 +1938,16 @@ class WebViewHandler(DesktopViewHandler):
1239
1938
 
1240
1939
 
1241
1940
  class PressableHandler(DesktopViewHandler):
1242
- """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
+ """
1243
1951
 
1244
1952
  def build(self, props: Dict[str, Any]) -> Any:
1245
1953
  frame = tk.Frame(_master(), highlightthickness=0, bd=0, cursor="hand2")
@@ -1252,7 +1960,8 @@ class PressableHandler(DesktopViewHandler):
1252
1960
  frame._pn_long_fired = False
1253
1961
  self._cancel_long(frame)
1254
1962
  _fire(frame, "on_press_out")
1255
- 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:
1256
1965
  _fire(frame, "on_press")
1257
1966
 
1258
1967
  def _on_press_down(_event: Any = None) -> None:
@@ -1265,13 +1974,19 @@ class PressableHandler(DesktopViewHandler):
1265
1974
  frame._pn_long_fired = True
1266
1975
  _fire(frame, "on_long_press")
1267
1976
 
1268
- def _on_leave(_event: Any = None) -> None:
1269
- 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)
1270
1984
 
1271
1985
  try:
1272
1986
  frame.bind("<ButtonRelease-1>", _on_release, add="+")
1273
1987
  frame.bind("<ButtonPress-1>", _on_press_down, add="+")
1274
- frame.bind("<Leave>", _on_leave, add="+")
1988
+ frame.bind("<B1-Motion>", _on_drift, add="+")
1989
+ frame.bind("<Leave>", _on_drift, add="+")
1275
1990
  except Exception:
1276
1991
  diagnostics.swallowed("desktop.PressableHandler._bind")
1277
1992
 
@@ -1597,9 +2312,12 @@ def register_handlers(registry: Any) -> None:
1597
2312
  """Register every built-in desktop handler on ``registry``.
1598
2313
 
1599
2314
  Mirrors ``register_handlers`` in the iOS / Android backends so the
1600
- desktop registry services the same element types. Lists
1601
- (``FlatList`` / ``SectionList``) need no handler: they are Python
1602
- 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.
1603
2321
  """
1604
2322
  flex = FlexContainerHandler()
1605
2323
  registry.register("View", flex)
@@ -1627,3 +2345,4 @@ def register_handlers(registry: Any) -> None:
1627
2345
  registry.register("Checkbox", CheckboxHandler())
1628
2346
  registry.register("SegmentedControl", SegmentedControlHandler())
1629
2347
  registry.register("DatePicker", DatePickerHandler())
2348
+ registry.register("VirtualList", VirtualListHandler())