pythonnative 0.20.0__py3-none-any.whl → 0.22.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 (33) hide show
  1. pythonnative/__init__.py +14 -3
  2. pythonnative/animated.py +420 -135
  3. pythonnative/cli/pn.py +450 -956
  4. pythonnative/components.py +519 -235
  5. pythonnative/events.py +210 -0
  6. pythonnative/gestures.py +875 -0
  7. pythonnative/layout.py +463 -149
  8. pythonnative/mutations.py +130 -0
  9. pythonnative/native_views/__init__.py +161 -97
  10. pythonnative/native_views/android.py +1050 -1124
  11. pythonnative/native_views/base.py +108 -18
  12. pythonnative/native_views/desktop.py +460 -417
  13. pythonnative/native_views/ios.py +1918 -1916
  14. pythonnative/project/__init__.py +68 -0
  15. pythonnative/project/android.py +504 -0
  16. pythonnative/project/builder.py +555 -0
  17. pythonnative/project/config.py +642 -0
  18. pythonnative/project/doctor.py +233 -0
  19. pythonnative/project/icons.py +247 -0
  20. pythonnative/project/ios.py +344 -0
  21. pythonnative/project/permissions.py +343 -0
  22. pythonnative/project/runtime_assets.py +272 -0
  23. pythonnative/reconciler.py +540 -470
  24. pythonnative/screen.py +5 -2
  25. pythonnative/sdk/_components.py +2 -2
  26. pythonnative/templates/android_template/app/build.gradle +2 -0
  27. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +10 -2
  28. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +32 -21
  29. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
  30. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
  31. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
  32. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
  33. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/top_level.txt +0 -0
@@ -10,28 +10,41 @@ flex engine** in [`pythonnative.layout`][pythonnative.layout]: the
10
10
  reconciler computes each view's ``(x, y, width, height)`` in points and
11
11
  [`set_frame`][pythonnative.native_views.desktop.DesktopViewHandler.set_frame]
12
12
  applies it. Handlers therefore only deal with *visual* props (text,
13
- colors, fonts, callbacks) and ignore everything in
13
+ colors, fonts) and ignore everything in
14
14
  [`LAYOUT_STYLE_KEYS`][pythonnative.layout.LAYOUT_STYLE_KEYS].
15
15
 
16
+ Event contract
17
+ --------------
18
+ Handlers never see Python callables. Each view stores its reconciler
19
+ tag (``widget._pn_tag``); Tk callbacks forward through
20
+ [`dispatch_event`][pythonnative.events.dispatch_event] with that tag,
21
+ and the Python-side [`EventRegistry`][pythonnative.events.EventRegistry]
22
+ routes to whatever closure the current render registered. Views with a
23
+ ``gestures`` prop feed Tk pointer events into the shared pure-Python
24
+ [`GestureArbiter`][pythonnative.gestures.GestureArbiter].
25
+
16
26
  Placement strategy
17
27
  ------------------
18
28
  Tkinter fixes a widget's master at construction time, but the
19
- reconciler creates a view *before* it knows the parent (``create`` then
20
- ``add_child``). To bridge that, every widget is created under a single
29
+ reconciler creates views before parents (``CreateOp`` before
30
+ ``InsertOp``). To bridge that, every widget is created under a single
21
31
  shared *stage* frame (see
22
32
  [`set_root_container`][pythonnative.native_views.desktop.set_root_container])
23
33
  and positioned with ``place(in_=parent, ...)``. Tk's ``-in`` option
24
34
  composes coordinates through nested parents, so the engine's
25
35
  parent-relative frames render correctly without reparenting.
36
+ ScrollViews shift their children's placement by the current scroll
37
+ offset, which yields real wheel scrolling in the preview.
26
38
 
27
39
  Scope
28
40
  -----
29
41
  This is a **preview** backend, not a production desktop target. It
30
42
  favors fidelity of layout and behavior over pixel-perfect chrome:
31
43
  rounded corners, shadows, per-widget opacity, and overflow clipping are
32
- approximated or omitted (Tkinter can't express them cheaply). Every one
33
- of the 25 built-in element types is handled so any app renders without
34
- errors.
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``.
35
48
 
36
49
  This module imports ``tkinter`` at import time, so it is only imported
37
50
  when ``PN_PLATFORM=desktop``. Off-device unit tests inject a mock
@@ -43,11 +56,14 @@ from __future__ import annotations
43
56
 
44
57
  import math
45
58
  import re
59
+ import time
46
60
  import tkinter as tk
47
61
  from tkinter import font as tkfont
48
62
  from tkinter import ttk
49
63
  from typing import Any, Dict, List, Optional, Tuple
50
64
 
65
+ from ..events import dispatch_event, event_names
66
+ from ..gestures import make_arbiter
51
67
  from .base import ViewHandler
52
68
 
53
69
  # ======================================================================
@@ -67,12 +83,14 @@ _DEFAULT_FONT_SIZE = 15
67
83
  def set_root_container(container: Any) -> None:
68
84
  """Install the stage frame that every desktop view is created under.
69
85
 
70
- Called by ``pythonnative.preview`` before the
71
- first screen is mounted. ``container`` must be a Tk widget (a
72
- ``Frame`` filling the preview window).
86
+ Called by ``pythonnative.preview`` before the first screen is
87
+ mounted. ``container`` must be a Tk widget (a ``Frame`` filling the
88
+ preview window). Also installs the global mouse-wheel binding that
89
+ powers ScrollView scrolling.
73
90
  """
74
91
  global _ROOT_CONTAINER
75
92
  _ROOT_CONTAINER = container
93
+ _install_wheel_bindings(container)
76
94
 
77
95
 
78
96
  def get_root_container() -> Any:
@@ -248,7 +266,25 @@ def _finite(value: Any, default: float = 0.0) -> float:
248
266
 
249
267
 
250
268
  # ======================================================================
251
- # Placement (ordering-independent)
269
+ # Event dispatch helpers
270
+ # ======================================================================
271
+
272
+
273
+ def _fire(widget: Any, name: str, *args: Any) -> None:
274
+ """Dispatch event ``name`` for ``widget`` through the tag registry."""
275
+ tag = getattr(widget, "_pn_tag", None)
276
+ if tag is not None:
277
+ dispatch_event(tag, name, *args)
278
+
279
+
280
+ def _has_event(widget: Any, name: str) -> bool:
281
+ """Whether the element wired a callback named ``name`` this render."""
282
+ merged = getattr(widget, "_pn_props", None) or {}
283
+ return name in event_names(merged)
284
+
285
+
286
+ # ======================================================================
287
+ # Placement (ordering-independent, scroll-aware)
252
288
  # ======================================================================
253
289
 
254
290
 
@@ -269,10 +305,11 @@ def _place(widget: Any) -> None:
269
305
  """Position ``widget`` inside its logical parent, if both are known.
270
306
 
271
307
  Idempotent and order-independent: ``set_frame`` records the frame
272
- and ``add_child`` records the parent; whichever runs second triggers
273
- the actual ``place``. Coordinates compose through nested ``-in``
274
- parents, so a child's parent-relative frame lands at the right
275
- absolute spot.
308
+ and ``insert_child`` records the parent; whichever runs second
309
+ triggers the actual ``place``. Coordinates compose through nested
310
+ ``-in`` parents, so a child's parent-relative frame lands at the
311
+ right absolute spot. A scrollable parent shifts every child by its
312
+ current scroll offset.
276
313
  """
277
314
  frame = getattr(widget, "_pn_frame", None)
278
315
  if frame is None:
@@ -283,13 +320,32 @@ def _place(widget: Any) -> None:
283
320
  return
284
321
  x, y, w, h = frame
285
322
  tx, ty = getattr(widget, "_pn_translate", (0.0, 0.0))
323
+ sx, sy = getattr(target, "_pn_scroll_offset", (0.0, 0.0)) if parent is not None else (0.0, 0.0)
286
324
  try:
287
- widget.place(in_=target, x=x + tx, y=y + ty, width=max(0.0, w), height=max(0.0, h))
325
+ widget.place(in_=target, x=x + tx - sx, y=y + ty - sy, width=max(0.0, w), height=max(0.0, h))
288
326
  widget.lift()
289
327
  except Exception:
290
328
  pass
291
329
 
292
330
 
331
+ def _register_child(parent: Any, child: Any, index: int) -> None:
332
+ """Track ``child`` in ``parent``'s ordered child list at ``index``."""
333
+ children: List[Any] = getattr(parent, "_pn_children", None) or []
334
+ if child in children:
335
+ children.remove(child)
336
+ children.insert(min(max(index, 0), len(children)), child)
337
+ parent._pn_children = children
338
+
339
+
340
+ def _unregister_child(parent: Any, child: Any) -> None:
341
+ children: List[Any] = getattr(parent, "_pn_children", None) or []
342
+ try:
343
+ children.remove(child)
344
+ except ValueError:
345
+ pass
346
+ parent._pn_children = children
347
+
348
+
293
349
  def _set_translate_from_transform(widget: Any, spec: Any) -> None:
294
350
  """Extract a translate offset from a ``transform`` prop for placement.
295
351
 
@@ -340,34 +396,229 @@ def _apply_common(widget: Any, props: Dict[str, Any]) -> None:
340
396
 
341
397
 
342
398
  # ======================================================================
343
- # Base handler
399
+ # Gesture wiring (pure-Python arbiter over Tk pointer events)
344
400
  # ======================================================================
345
401
 
346
402
 
347
- class DesktopViewHandler(ViewHandler):
348
- """Shared ``set_frame`` / child / measure behavior for Tk handlers.
403
+ def _wire_gestures(widget: Any, specs: Any) -> None:
404
+ """Feed Tk pointer events on ``widget`` into a `GestureArbiter`.
349
405
 
350
- Concrete handlers implement ``create`` / ``update`` (and optionally
351
- ``measure_intrinsic``); child management and frame application are
352
- inherited and route through the order-independent
353
- [`_place`][pythonnative.native_views.desktop._place] helper.
406
+ The arbiter emits ``(gesture_index, payload)`` pairs which are
407
+ forwarded as ``gesture:<i>`` events for this widget's tag. Long
408
+ presses use Tk's ``after`` timer to poll the arbiter at its next
409
+ deadline.
354
410
  """
411
+ if not isinstance(specs, (list, tuple)) or not specs:
412
+ widget._pn_arbiter = None
413
+ return
355
414
 
356
- def add_child(self, parent: Any, child: Any) -> None:
357
- child._pn_parent = parent
415
+ def _emit(index: int, payload: Dict[str, Any]) -> None:
416
+ _fire(widget, f"gesture:{index}", payload)
417
+
418
+ arbiter = make_arbiter([s for s in specs if isinstance(s, dict)], _emit)
419
+ widget._pn_arbiter = arbiter
420
+ if getattr(widget, "_pn_gestures_bound", False):
421
+ return
422
+ widget._pn_gestures_bound = True
423
+
424
+ def _schedule_poll() -> None:
425
+ current = getattr(widget, "_pn_arbiter", None)
426
+ if current is None:
427
+ return
428
+ deadline = current.next_deadline()
429
+ if deadline is None:
430
+ return
431
+ delay_ms = max(1, int((deadline - time.monotonic()) * 1000.0))
432
+
433
+ def _poll() -> None:
434
+ live = getattr(widget, "_pn_arbiter", None)
435
+ if live is not None:
436
+ live.poll(time.monotonic())
437
+ _schedule_poll()
438
+
439
+ try:
440
+ widget.after(delay_ms, _poll)
441
+ except Exception:
442
+ pass
443
+
444
+ def _on_down(event: Any) -> None:
445
+ current = getattr(widget, "_pn_arbiter", None)
446
+ if current is not None:
447
+ current.pointer_down(0, float(event.x), float(event.y), time.monotonic())
448
+ _schedule_poll()
449
+
450
+ def _on_move(event: Any) -> None:
451
+ current = getattr(widget, "_pn_arbiter", None)
452
+ if current is not None:
453
+ current.pointer_move(0, float(event.x), float(event.y), time.monotonic())
454
+
455
+ def _on_up(event: Any) -> None:
456
+ current = getattr(widget, "_pn_arbiter", None)
457
+ if current is not None:
458
+ current.pointer_up(0, float(event.x), float(event.y), time.monotonic())
459
+
460
+ try:
461
+ widget.bind("<ButtonPress-1>", _on_down, add="+")
462
+ widget.bind("<B1-Motion>", _on_move, add="+")
463
+ widget.bind("<ButtonRelease-1>", _on_up, add="+")
464
+ except Exception:
465
+ pass
466
+
467
+
468
+ # ======================================================================
469
+ # ScrollView wheel support
470
+ # ======================================================================
471
+
472
+ _WHEEL_BOUND = False
473
+
474
+
475
+ def _install_wheel_bindings(container: Any) -> None:
476
+ """Install the global wheel handler that drives preview scrolling.
477
+
478
+ Tk pointer events don't bubble, so a single ``bind_all`` on the
479
+ toplevel hit-tests the widget under the cursor and walks the
480
+ logical ``_pn_parent`` chain to the nearest scrollable ancestor.
481
+ """
482
+ global _WHEEL_BOUND
483
+ if _WHEEL_BOUND:
484
+ return
485
+ try:
486
+ top = container.winfo_toplevel()
487
+ except Exception:
488
+ return
489
+
490
+ def _on_wheel(event: Any) -> None:
491
+ delta = getattr(event, "delta", 0)
492
+ if getattr(event, "num", None) == 4:
493
+ delta = 120
494
+ elif getattr(event, "num", None) == 5:
495
+ delta = -120
496
+ if not delta:
497
+ return
498
+ try:
499
+ under = event.widget.winfo_containing(event.x_root, event.y_root)
500
+ except Exception:
501
+ under = None
502
+ node = under
503
+ while node is not None:
504
+ if getattr(node, "_pn_scrollable", False):
505
+ _scroll_by(node, delta)
506
+ return
507
+ node = getattr(node, "_pn_parent", None)
508
+
509
+ try:
510
+ top.bind_all("<MouseWheel>", _on_wheel, add="+")
511
+ top.bind_all("<Button-4>", _on_wheel, add="+")
512
+ top.bind_all("<Button-5>", _on_wheel, add="+")
513
+ _WHEEL_BOUND = True
514
+ except Exception:
515
+ pass
516
+
517
+
518
+ def _content_extent(widget: Any) -> Tuple[float, float]:
519
+ """Max (right, bottom) edge over the scroll container's children."""
520
+ max_x = 0.0
521
+ max_y = 0.0
522
+ for child in getattr(widget, "_pn_children", []) or []:
523
+ frame = getattr(child, "_pn_frame", None)
524
+ if frame is None:
525
+ continue
526
+ x, y, w, h = frame
527
+ max_x = max(max_x, x + w)
528
+ max_y = max(max_y, y + h)
529
+ return (max_x, max_y)
530
+
531
+
532
+ def _scroll_to(widget: Any, x: float, y: float, fire_event: bool = True) -> None:
533
+ """Set a scroll container's offset, clamped to its content extent."""
534
+ frame = getattr(widget, "_pn_frame", None)
535
+ vw = frame[2] if frame else 0.0
536
+ vh = frame[3] if frame else 0.0
537
+ content_w, content_h = _content_extent(widget)
538
+ max_x = max(0.0, content_w - vw)
539
+ max_y = max(0.0, content_h - vh)
540
+ new_offset = (min(max(0.0, x), max_x), min(max(0.0, y), max_y))
541
+ if new_offset == getattr(widget, "_pn_scroll_offset", (0.0, 0.0)):
542
+ return
543
+ widget._pn_scroll_offset = new_offset
544
+ for child in getattr(widget, "_pn_children", []) or []:
358
545
  _place(child)
546
+ if fire_event:
547
+ _fire(widget, "on_scroll", {"x": new_offset[0], "y": new_offset[1]})
548
+
549
+
550
+ def _scroll_by(widget: Any, wheel_delta: float) -> None:
551
+ sx, sy = getattr(widget, "_pn_scroll_offset", (0.0, 0.0))
552
+ step = -wheel_delta # natural direction: wheel up scrolls content up
553
+ horizontal = (getattr(widget, "_pn_props", {}) or {}).get("scroll_axis") == "horizontal"
554
+ if horizontal:
555
+ _scroll_to(widget, sx + step, sy)
556
+ else:
557
+ _scroll_to(widget, sx, sy + step)
558
+
559
+
560
+ # ======================================================================
561
+ # Base handler
562
+ # ======================================================================
563
+
564
+
565
+ class DesktopViewHandler(ViewHandler):
566
+ """Shared create/update/frame/child behavior for Tk handlers.
567
+
568
+ Concrete handlers implement
569
+ [`build`][pythonnative.native_views.desktop.DesktopViewHandler.build]
570
+ (construct the widget) and optionally
571
+ [`apply`][pythonnative.native_views.desktop.DesktopViewHandler.apply]
572
+ (apply visual props); creation bookkeeping (tag stamping, prop
573
+ merging, gesture wiring) is inherited.
574
+ """
575
+
576
+ def build(self, props: Dict[str, Any]) -> Any:
577
+ """Construct and return the bare Tk widget."""
578
+ return tk.Frame(_master(), highlightthickness=0, bd=0)
579
+
580
+ def apply(self, widget: Any, props: Dict[str, Any]) -> None:
581
+ """Apply changed visual props (`_merge_props` has already run)."""
582
+ _apply_common(widget, getattr(widget, "_pn_props", props))
583
+
584
+ def create(self, tag: int, props: Dict[str, Any]) -> Any:
585
+ widget = self.build(props)
586
+ widget._pn_tag = tag
587
+ _merge_props(widget, props)
588
+ self.apply(widget, props)
589
+ if "gestures" in props:
590
+ _wire_gestures(widget, props.get("gestures"))
591
+ return widget
592
+
593
+ def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None:
594
+ _merge_props(native_view, changed_props)
595
+ self.apply(native_view, changed_props)
596
+ if "gestures" in changed_props:
597
+ _wire_gestures(native_view, changed_props.get("gestures"))
359
598
 
360
599
  def insert_child(self, parent: Any, child: Any, index: int) -> None:
361
600
  child._pn_parent = parent
601
+ _register_child(parent, child, index)
362
602
  _place(child)
363
603
 
364
604
  def remove_child(self, parent: Any, child: Any) -> None:
605
+ _unregister_child(parent, child)
365
606
  try:
366
607
  child.place_forget()
367
608
  except Exception:
368
609
  pass
369
610
  child._pn_parent = None
370
611
 
612
+ def destroy(self, native_view: Any) -> None:
613
+ parent = getattr(native_view, "_pn_parent", None)
614
+ if parent is not None:
615
+ _unregister_child(parent, native_view)
616
+ native_view._pn_arbiter = None
617
+ try:
618
+ native_view.destroy()
619
+ except Exception:
620
+ pass
621
+
371
622
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
372
623
  if native_view is None:
373
624
  return
@@ -377,19 +628,12 @@ class DesktopViewHandler(ViewHandler):
377
628
  def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
378
629
  return (0.0, 0.0)
379
630
 
380
- def set_animated_property(
381
- self,
382
- native_view: Any,
383
- prop_name: str,
384
- value: Any,
385
- duration_ms: float = 0.0,
386
- easing: str = "linear",
387
- ) -> None:
388
- """Apply the final value of an animated property (no tween).
389
-
390
- The preview shows animation *end states* rather than smooth
391
- interpolation. Translation maps onto placement; opacity, scale,
392
- and rotation have no cheap Tk analogue and are skipped.
631
+ def set_animated_property(self, native_view: Any, prop_name: str, value: Any) -> None:
632
+ """Apply one frame of a Python-driven animation.
633
+
634
+ Translation maps onto placement; background color maps onto
635
+ ``configure``. Opacity, scale, and rotation have no cheap Tk
636
+ analogue and are skipped (a documented preview limitation).
393
637
  """
394
638
  if native_view is None:
395
639
  return
@@ -411,7 +655,7 @@ class DesktopViewHandler(ViewHandler):
411
655
 
412
656
 
413
657
  # ======================================================================
414
- # Containers (View / Column / Row / SafeAreaView / KeyboardAvoidingView)
658
+ # Containers (View / SafeAreaView / KeyboardAvoidingView)
415
659
  # ======================================================================
416
660
 
417
661
 
@@ -423,30 +667,58 @@ class FlexContainerHandler(DesktopViewHandler):
423
667
  border).
424
668
  """
425
669
 
426
- def create(self, props: Dict[str, Any]) -> Any:
427
- frame = tk.Frame(_master(), highlightthickness=0, bd=0)
428
- _apply_common(frame, _merge_props(frame, props))
429
- return frame
430
670
 
431
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
432
- _apply_common(native_view, _merge_props(native_view, changed))
671
+ class SafeAreaViewHandler(FlexContainerHandler):
672
+ """Desktop has no notch/home-indicator insets, so this is a frame."""
673
+
674
+
675
+ class KeyboardAvoidingViewHandler(FlexContainerHandler):
676
+ """No soft keyboard on desktop; behaves as a plain frame."""
677
+
678
+
679
+ # ======================================================================
680
+ # ScrollView
681
+ # ======================================================================
433
682
 
434
683
 
435
684
  class ScrollViewHandler(FlexContainerHandler):
436
- """Preview ScrollView a plain frame.
685
+ """Preview ScrollView with real wheel scrolling.
437
686
 
438
- The layout engine still lets the content grow past the viewport on
439
- the scroll axis; the desktop preview renders that overflow without
440
- interactive scrolling or clipping (a documented preview limitation).
441
- """
687
+ The layout engine lets the content grow past the viewport on the
688
+ scroll axis; this handler offsets its children's placement by the
689
+ current scroll offset (overflow outside the frame is *not* clipped
690
+ — a documented preview limitation of the single-stage design).
442
691
 
692
+ Commands:
693
+ ``scroll_to_offset(x=…, y=…)``: jump to an offset.
694
+ ``scroll_to_end()``: jump to the end of the content.
695
+ """
443
696
 
444
- class SafeAreaViewHandler(FlexContainerHandler):
445
- """Desktop has no notch/home-indicator insets, so this is a frame."""
697
+ def build(self, props: Dict[str, Any]) -> Any:
698
+ frame = tk.Frame(_master(), highlightthickness=0, bd=0)
699
+ frame._pn_scrollable = True
700
+ frame._pn_scroll_offset = (0.0, 0.0)
701
+ return frame
446
702
 
703
+ def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
704
+ if name == "scroll_to_offset":
705
+ sx, sy = getattr(native_view, "_pn_scroll_offset", (0.0, 0.0))
706
+ _scroll_to(native_view, _finite(args.get("x", sx)), _finite(args.get("y", sy)))
707
+ return True
708
+ if name == "scroll_to_end":
709
+ content_w, content_h = _content_extent(native_view)
710
+ _scroll_to(native_view, content_w, content_h)
711
+ return True
712
+ if name == "get_scroll_offset":
713
+ sx, sy = getattr(native_view, "_pn_scroll_offset", (0.0, 0.0))
714
+ return {"x": sx, "y": sy}
715
+ return None
447
716
 
448
- class KeyboardAvoidingViewHandler(FlexContainerHandler):
449
- """No soft keyboard on desktop; behaves as a plain frame."""
717
+ def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
718
+ super().set_frame(native_view, x, y, width, height)
719
+ # Keep the offset clamped when the viewport grows.
720
+ sx, sy = getattr(native_view, "_pn_scroll_offset", (0.0, 0.0))
721
+ _scroll_to(native_view, sx, sy, fire_event=False)
450
722
 
451
723
 
452
724
  # ======================================================================
@@ -459,16 +731,11 @@ _JUSTIFY_FOR_ALIGN = {"left": "left", "center": "center", "right": "right"}
459
731
 
460
732
 
461
733
  class TextHandler(DesktopViewHandler):
462
- def create(self, props: Dict[str, Any]) -> Any:
463
- label = tk.Label(_master(), highlightthickness=0, bd=0, padx=0, pady=0)
464
- self._apply(label, props)
465
- return label
466
-
467
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
468
- self._apply(native_view, changed)
734
+ def build(self, props: Dict[str, Any]) -> Any:
735
+ return tk.Label(_master(), highlightthickness=0, bd=0, padx=0, pady=0)
469
736
 
470
- def _apply(self, label: Any, props: Dict[str, Any]) -> None:
471
- merged = _merge_props(label, props)
737
+ def apply(self, label: Any, props: Dict[str, Any]) -> None:
738
+ merged = getattr(label, "_pn_props", props)
472
739
  text = merged.get("text")
473
740
  label._pn_text = "" if text is None else str(text)
474
741
  font = _make_font(merged)
@@ -513,16 +780,13 @@ class TextHandler(DesktopViewHandler):
513
780
 
514
781
 
515
782
  class ButtonHandler(DesktopViewHandler):
516
- def create(self, props: Dict[str, Any]) -> Any:
783
+ def build(self, props: Dict[str, Any]) -> Any:
517
784
  button = tk.Button(_master(), highlightthickness=0, takefocus=0)
518
- self._apply(button, props)
785
+ button.configure(command=lambda: _fire(button, "on_click"))
519
786
  return button
520
787
 
521
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
522
- self._apply(native_view, changed)
523
-
524
- def _apply(self, button: Any, props: Dict[str, Any]) -> None:
525
- merged = _merge_props(button, props)
788
+ def apply(self, button: Any, props: Dict[str, Any]) -> None:
789
+ merged = getattr(button, "_pn_props", props)
526
790
  title = merged.get("title")
527
791
  button._pn_text = "" if title is None else str(title)
528
792
  font = _make_font(merged)
@@ -541,20 +805,6 @@ class ButtonHandler(DesktopViewHandler):
541
805
  button.configure(**opts)
542
806
  except Exception:
543
807
  pass
544
- if "on_click" in props:
545
- callback = props["on_click"]
546
-
547
- def _command() -> None:
548
- if callable(callback):
549
- try:
550
- callback()
551
- except Exception:
552
- pass
553
-
554
- try:
555
- button.configure(command=_command if callable(callback) else "")
556
- except Exception:
557
- pass
558
808
  _apply_common(button, merged)
559
809
 
560
810
  def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
@@ -572,7 +822,7 @@ class ButtonHandler(DesktopViewHandler):
572
822
 
573
823
 
574
824
  class TextInputHandler(DesktopViewHandler):
575
- def create(self, props: Dict[str, Any]) -> Any:
825
+ def build(self, props: Dict[str, Any]) -> Any:
576
826
  multiline = bool(props.get("multiline"))
577
827
  widget: Any
578
828
  if multiline:
@@ -581,13 +831,9 @@ class TextInputHandler(DesktopViewHandler):
581
831
  widget = tk.Entry(_master(), highlightthickness=1, bd=0)
582
832
  widget._pn_multiline = multiline
583
833
  widget._pn_suppress = False
584
- self._bind(widget, props)
585
- self._apply(widget, props)
834
+ self._bind(widget)
586
835
  return widget
587
836
 
588
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
589
- self._apply(native_view, changed)
590
-
591
837
  def _current_text(self, widget: Any) -> str:
592
838
  try:
593
839
  if getattr(widget, "_pn_multiline", False):
@@ -610,39 +856,33 @@ class TextInputHandler(DesktopViewHandler):
610
856
  finally:
611
857
  widget._pn_suppress = False
612
858
 
613
- def _bind(self, widget: Any, props: Dict[str, Any]) -> None:
859
+ def _bind(self, widget: Any) -> None:
614
860
  def _on_key(_event: Any = None) -> None:
615
861
  if getattr(widget, "_pn_suppress", False):
616
862
  return
617
- callback = getattr(widget, "_pn_on_change", None)
618
- if callable(callback):
619
- try:
620
- callback(self._current_text(widget))
621
- except Exception:
622
- pass
863
+ _fire(widget, "on_change", self._current_text(widget))
623
864
 
624
865
  def _on_return(_event: Any = None) -> str:
625
- callback = getattr(widget, "_pn_on_submit", None)
626
- if callable(callback):
627
- try:
628
- callback(self._current_text(widget))
629
- except Exception:
630
- pass
866
+ _fire(widget, "on_submit", self._current_text(widget))
631
867
  return "break"
632
868
 
869
+ def _on_focus(_event: Any = None) -> None:
870
+ _fire(widget, "on_focus")
871
+
872
+ def _on_blur(_event: Any = None) -> None:
873
+ _fire(widget, "on_blur")
874
+
633
875
  try:
634
876
  widget.bind("<KeyRelease>", _on_key)
877
+ widget.bind("<FocusIn>", _on_focus)
878
+ widget.bind("<FocusOut>", _on_blur)
635
879
  if not getattr(widget, "_pn_multiline", False):
636
880
  widget.bind("<Return>", _on_return)
637
881
  except Exception:
638
882
  pass
639
883
 
640
- def _apply(self, widget: Any, props: Dict[str, Any]) -> None:
641
- merged = _merge_props(widget, props)
642
- if "on_change" in props:
643
- widget._pn_on_change = props["on_change"]
644
- if "on_submit" in props:
645
- widget._pn_on_submit = props["on_submit"]
884
+ def apply(self, widget: Any, props: Dict[str, Any]) -> None:
885
+ merged = getattr(widget, "_pn_props", props)
646
886
  opts: Dict[str, Any] = {"font": _make_font(merged)}
647
887
  color = _tk_color(merged.get("color"))
648
888
  if color is not None:
@@ -668,6 +908,21 @@ class TextInputHandler(DesktopViewHandler):
668
908
  except Exception:
669
909
  pass
670
910
 
911
+ def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
912
+ if name == "focus":
913
+ try:
914
+ native_view.focus_set()
915
+ except Exception:
916
+ pass
917
+ return True
918
+ if name == "blur":
919
+ try:
920
+ native_view.winfo_toplevel().focus_set()
921
+ except Exception:
922
+ pass
923
+ return True
924
+ return None
925
+
671
926
  def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
672
927
  merged = getattr(native_view, "_pn_props", {}) or {}
673
928
  font = _make_font(merged)
@@ -692,16 +947,11 @@ class ImageHandler(DesktopViewHandler):
692
947
  ``PhotoImage`` (Tk garbage-collects images that aren't referenced).
693
948
  """
694
949
 
695
- def create(self, props: Dict[str, Any]) -> Any:
696
- label = tk.Label(_master(), highlightthickness=0, bd=0, background="#d1d1d6")
697
- self._apply(label, props)
698
- return label
699
-
700
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
701
- self._apply(native_view, changed)
950
+ def build(self, props: Dict[str, Any]) -> Any:
951
+ return tk.Label(_master(), highlightthickness=0, bd=0, background="#d1d1d6")
702
952
 
703
- def _apply(self, label: Any, props: Dict[str, Any]) -> None:
704
- merged = _merge_props(label, props)
953
+ def apply(self, label: Any, props: Dict[str, Any]) -> None:
954
+ merged = getattr(label, "_pn_props", props)
705
955
  if "source" in props:
706
956
  source = props.get("source")
707
957
  photo = None
@@ -737,35 +987,15 @@ class ImageHandler(DesktopViewHandler):
737
987
 
738
988
 
739
989
  class SwitchHandler(DesktopViewHandler):
740
- def create(self, props: Dict[str, Any]) -> Any:
990
+ def build(self, props: Dict[str, Any]) -> Any:
741
991
  var = tk.IntVar(master=_master(), value=1 if props.get("value") else 0)
742
992
  check = tk.Checkbutton(_master(), variable=var, takefocus=0, highlightthickness=0, text="")
743
993
  check._pn_var = var
744
- self._bind(check, props)
745
- self._apply(check, props)
994
+ check.configure(command=lambda: _fire(check, "on_change", bool(var.get())))
746
995
  return check
747
996
 
748
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
749
- self._apply(native_view, changed)
750
-
751
- def _bind(self, check: Any, props: Dict[str, Any]) -> None:
752
- def _command() -> None:
753
- callback = getattr(check, "_pn_on_change", None)
754
- if callable(callback):
755
- try:
756
- callback(bool(check._pn_var.get()))
757
- except Exception:
758
- pass
759
-
760
- try:
761
- check.configure(command=_command)
762
- except Exception:
763
- pass
764
-
765
- def _apply(self, check: Any, props: Dict[str, Any]) -> None:
766
- merged = _merge_props(check, props)
767
- if "on_change" in props:
768
- check._pn_on_change = props["on_change"]
997
+ def apply(self, check: Any, props: Dict[str, Any]) -> None:
998
+ merged = getattr(check, "_pn_props", props)
769
999
  if "value" in props:
770
1000
  try:
771
1001
  check._pn_var.set(1 if props.get("value") else 0)
@@ -778,35 +1008,15 @@ class SwitchHandler(DesktopViewHandler):
778
1008
 
779
1009
 
780
1010
  class CheckboxHandler(DesktopViewHandler):
781
- def create(self, props: Dict[str, Any]) -> Any:
1011
+ def build(self, props: Dict[str, Any]) -> Any:
782
1012
  var = tk.IntVar(master=_master(), value=1 if props.get("value") else 0)
783
1013
  check = tk.Checkbutton(_master(), variable=var, takefocus=0, highlightthickness=0, anchor="w")
784
1014
  check._pn_var = var
785
- self._bind(check, props)
786
- self._apply(check, props)
1015
+ check.configure(command=lambda: _fire(check, "on_change", bool(var.get())))
787
1016
  return check
788
1017
 
789
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
790
- self._apply(native_view, changed)
791
-
792
- def _bind(self, check: Any, props: Dict[str, Any]) -> None:
793
- def _command() -> None:
794
- callback = getattr(check, "_pn_on_change", None)
795
- if callable(callback):
796
- try:
797
- callback(bool(check._pn_var.get()))
798
- except Exception:
799
- pass
800
-
801
- try:
802
- check.configure(command=_command)
803
- except Exception:
804
- pass
805
-
806
- def _apply(self, check: Any, props: Dict[str, Any]) -> None:
807
- merged = _merge_props(check, props)
808
- if "on_change" in props:
809
- check._pn_on_change = props["on_change"]
1018
+ def apply(self, check: Any, props: Dict[str, Any]) -> None:
1019
+ merged = getattr(check, "_pn_props", props)
810
1020
  opts: Dict[str, Any] = {}
811
1021
  if "label" in merged:
812
1022
  opts["text"] = "" if merged.get("label") is None else str(merged["label"])
@@ -834,7 +1044,7 @@ class CheckboxHandler(DesktopViewHandler):
834
1044
 
835
1045
 
836
1046
  class SliderHandler(DesktopViewHandler):
837
- def create(self, props: Dict[str, Any]) -> Any:
1047
+ def build(self, props: Dict[str, Any]) -> Any:
838
1048
  scale = tk.Scale(
839
1049
  _master(),
840
1050
  orient="horizontal",
@@ -843,31 +1053,19 @@ class SliderHandler(DesktopViewHandler):
843
1053
  bd=0,
844
1054
  sliderlength=20,
845
1055
  )
846
- self._bind(scale, props)
847
- self._apply(scale, props)
848
- return scale
849
1056
 
850
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
851
- self._apply(native_view, changed)
852
-
853
- def _bind(self, scale: Any, props: Dict[str, Any]) -> None:
854
1057
  def _command(_value: Any) -> None:
855
- callback = getattr(scale, "_pn_on_change", None)
856
- if callable(callback):
1058
+ if not getattr(scale, "_pn_suppress", False):
857
1059
  try:
858
- callback(float(scale.get()))
1060
+ _fire(scale, "on_change", float(scale.get()))
859
1061
  except Exception:
860
1062
  pass
861
1063
 
862
- try:
863
- scale.configure(command=_command)
864
- except Exception:
865
- pass
1064
+ scale.configure(command=_command)
1065
+ return scale
866
1066
 
867
- def _apply(self, scale: Any, props: Dict[str, Any]) -> None:
868
- merged = _merge_props(scale, props)
869
- if "on_change" in props:
870
- scale._pn_on_change = props["on_change"]
1067
+ def apply(self, scale: Any, props: Dict[str, Any]) -> None:
1068
+ merged = getattr(scale, "_pn_props", props)
871
1069
  opts: Dict[str, Any] = {
872
1070
  "from_": _finite(merged.get("min_value", 0.0)),
873
1071
  "to": _finite(merged.get("max_value", 1.0)),
@@ -894,16 +1092,11 @@ class SliderHandler(DesktopViewHandler):
894
1092
 
895
1093
 
896
1094
  class ProgressBarHandler(DesktopViewHandler):
897
- def create(self, props: Dict[str, Any]) -> Any:
898
- bar = ttk.Progressbar(_master(), orient="horizontal", maximum=1.0)
899
- self._apply(bar, props)
900
- return bar
1095
+ def build(self, props: Dict[str, Any]) -> Any:
1096
+ return ttk.Progressbar(_master(), orient="horizontal", maximum=1.0)
901
1097
 
902
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
903
- self._apply(native_view, changed)
904
-
905
- def _apply(self, bar: Any, props: Dict[str, Any]) -> None:
906
- merged = _merge_props(bar, props)
1098
+ def apply(self, bar: Any, props: Dict[str, Any]) -> None:
1099
+ merged = getattr(bar, "_pn_props", props)
907
1100
  if merged.get("indeterminate"):
908
1101
  try:
909
1102
  bar.configure(mode="indeterminate")
@@ -922,16 +1115,11 @@ class ProgressBarHandler(DesktopViewHandler):
922
1115
 
923
1116
 
924
1117
  class ActivityIndicatorHandler(DesktopViewHandler):
925
- def create(self, props: Dict[str, Any]) -> Any:
926
- bar = ttk.Progressbar(_master(), orient="horizontal", mode="indeterminate", length=40)
927
- self._apply(bar, props)
928
- return bar
929
-
930
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
931
- self._apply(native_view, changed)
1118
+ def build(self, props: Dict[str, Any]) -> Any:
1119
+ return ttk.Progressbar(_master(), orient="horizontal", mode="indeterminate", length=40)
932
1120
 
933
- def _apply(self, bar: Any, props: Dict[str, Any]) -> None:
934
- merged = _merge_props(bar, props)
1121
+ def apply(self, bar: Any, props: Dict[str, Any]) -> None:
1122
+ merged = getattr(bar, "_pn_props", props)
935
1123
  try:
936
1124
  if merged.get("animating", True):
937
1125
  bar.start(50)
@@ -952,42 +1140,31 @@ class ActivityIndicatorHandler(DesktopViewHandler):
952
1140
 
953
1141
 
954
1142
  class SpacerHandler(DesktopViewHandler):
955
- def create(self, props: Dict[str, Any]) -> Any:
956
- return tk.Frame(_master(), highlightthickness=0, bd=0)
957
-
958
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1143
+ def apply(self, widget: Any, props: Dict[str, Any]) -> None:
959
1144
  pass
960
1145
 
961
1146
 
962
1147
  class StatusBarHandler(DesktopViewHandler):
963
1148
  """Desktop has no system status bar; render an inert zero-size frame."""
964
1149
 
965
- def create(self, props: Dict[str, Any]) -> Any:
966
- return tk.Frame(_master(), highlightthickness=0, bd=0)
967
-
968
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1150
+ def apply(self, widget: Any, props: Dict[str, Any]) -> None:
969
1151
  pass
970
1152
 
971
1153
 
972
1154
  class WebViewHandler(DesktopViewHandler):
973
1155
  """No embedded browser on desktop; show a labeled placeholder."""
974
1156
 
975
- def create(self, props: Dict[str, Any]) -> Any:
976
- label = tk.Label(
1157
+ def build(self, props: Dict[str, Any]) -> Any:
1158
+ return tk.Label(
977
1159
  _master(),
978
1160
  background="#1c1c1e",
979
1161
  foreground="#ffffff",
980
1162
  highlightthickness=0,
981
1163
  justify="center",
982
1164
  )
983
- self._apply(label, props)
984
- return label
985
-
986
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
987
- self._apply(native_view, changed)
988
1165
 
989
- def _apply(self, label: Any, props: Dict[str, Any]) -> None:
990
- merged = _merge_props(label, props)
1166
+ def apply(self, label: Any, props: Dict[str, Any]) -> None:
1167
+ merged = getattr(label, "_pn_props", props)
991
1168
  target = merged.get("url") or ("inline HTML" if merged.get("html") else "")
992
1169
  try:
993
1170
  label.configure(text=f"\U0001f310 WebView\n{target}")
@@ -1002,62 +1179,51 @@ class WebViewHandler(DesktopViewHandler):
1002
1179
 
1003
1180
 
1004
1181
  class PressableHandler(DesktopViewHandler):
1005
- """A frame that forwards click / long-press to its callbacks."""
1182
+ """A frame that forwards press / long-press / gestures."""
1006
1183
 
1007
- def create(self, props: Dict[str, Any]) -> Any:
1184
+ def build(self, props: Dict[str, Any]) -> Any:
1008
1185
  frame = tk.Frame(_master(), highlightthickness=0, bd=0, cursor="hand2")
1009
1186
  self._bind(frame)
1010
- self._apply(frame, props)
1011
1187
  return frame
1012
1188
 
1013
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1014
- self._apply(native_view, changed)
1015
-
1016
1189
  def _bind(self, frame: Any) -> None:
1017
- def _on_press(_event: Any = None) -> None:
1018
- callback = getattr(frame, "_pn_on_press", None)
1019
- if callable(callback):
1020
- try:
1021
- callback()
1022
- except Exception:
1023
- pass
1024
-
1025
- def _schedule_long(_event: Any = None) -> None:
1026
- callback = getattr(frame, "_pn_on_long_press", None)
1027
- if callable(callback):
1190
+ def _on_release(event: Any = None) -> None:
1191
+ fired_long = getattr(frame, "_pn_long_fired", False)
1192
+ frame._pn_long_fired = False
1193
+ self._cancel_long(frame)
1194
+ _fire(frame, "on_press_out")
1195
+ if not fired_long:
1196
+ _fire(frame, "on_press")
1197
+
1198
+ def _on_press_down(_event: Any = None) -> None:
1199
+ frame._pn_long_fired = False
1200
+ _fire(frame, "on_press_in")
1201
+ if _has_event(frame, "on_long_press"):
1028
1202
  frame._pn_long_after = frame.after(500, _fire_long)
1029
1203
 
1030
1204
  def _fire_long() -> None:
1031
- callback = getattr(frame, "_pn_on_long_press", None)
1032
- if callable(callback):
1033
- try:
1034
- callback()
1035
- except Exception:
1036
- pass
1205
+ frame._pn_long_fired = True
1206
+ _fire(frame, "on_long_press")
1037
1207
 
1038
- def _cancel_long(_event: Any = None) -> None:
1039
- after_id = getattr(frame, "_pn_long_after", None)
1040
- if after_id is not None:
1041
- try:
1042
- frame.after_cancel(after_id)
1043
- except Exception:
1044
- pass
1045
- frame._pn_long_after = None
1208
+ def _on_leave(_event: Any = None) -> None:
1209
+ self._cancel_long(frame)
1046
1210
 
1047
1211
  try:
1048
- frame.bind("<ButtonRelease-1>", _on_press)
1049
- frame.bind("<ButtonPress-1>", _schedule_long)
1050
- frame.bind("<Leave>", _cancel_long)
1212
+ frame.bind("<ButtonRelease-1>", _on_release, add="+")
1213
+ frame.bind("<ButtonPress-1>", _on_press_down, add="+")
1214
+ frame.bind("<Leave>", _on_leave, add="+")
1051
1215
  except Exception:
1052
1216
  pass
1053
1217
 
1054
- def _apply(self, frame: Any, props: Dict[str, Any]) -> None:
1055
- merged = _merge_props(frame, props)
1056
- if "on_press" in props:
1057
- frame._pn_on_press = props["on_press"]
1058
- if "on_long_press" in props:
1059
- frame._pn_on_long_press = props["on_long_press"]
1060
- _apply_common(frame, merged)
1218
+ @staticmethod
1219
+ def _cancel_long(frame: Any) -> None:
1220
+ after_id = getattr(frame, "_pn_long_after", None)
1221
+ if after_id is not None:
1222
+ try:
1223
+ frame.after_cancel(after_id)
1224
+ except Exception:
1225
+ pass
1226
+ frame._pn_long_after = None
1061
1227
 
1062
1228
 
1063
1229
  # ======================================================================
@@ -1074,21 +1240,11 @@ class ModalHandler(DesktopViewHandler):
1074
1240
  visibility and stacking.
1075
1241
  """
1076
1242
 
1077
- def create(self, props: Dict[str, Any]) -> Any:
1078
- frame = tk.Frame(_master(), highlightthickness=0, bd=0, background="#ffffff")
1079
- self._apply(frame, props)
1080
- return frame
1081
-
1082
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1083
- self._apply(native_view, changed)
1243
+ def build(self, props: Dict[str, Any]) -> Any:
1244
+ return tk.Frame(_master(), highlightthickness=0, bd=0, background="#ffffff")
1084
1245
 
1085
- def _apply(self, frame: Any, props: Dict[str, Any]) -> None:
1086
- merged = _merge_props(frame, props)
1087
- if merged.get("transparent"):
1088
- try:
1089
- frame.configure(background="#33000000".replace("33", "")) # solid fallback
1090
- except Exception:
1091
- pass
1246
+ def apply(self, frame: Any, props: Dict[str, Any]) -> None:
1247
+ merged = getattr(frame, "_pn_props", props)
1092
1248
  visible = bool(merged.get("visible"))
1093
1249
  stage = get_root_container()
1094
1250
  try:
@@ -1099,9 +1255,16 @@ class ModalHandler(DesktopViewHandler):
1099
1255
  frame.place_forget()
1100
1256
  except Exception:
1101
1257
  pass
1258
+ if visible != getattr(frame, "_pn_was_visible", None):
1259
+ frame._pn_was_visible = visible
1260
+ if visible:
1261
+ _fire(frame, "on_show")
1262
+ elif getattr(frame, "_pn_was_visible_once", False):
1263
+ _fire(frame, "on_dismiss")
1264
+ frame._pn_was_visible_once = True
1102
1265
 
1103
1266
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
1104
- # Modal placement is driven by visibility in ``_apply``; the
1267
+ # Modal placement is driven by visibility in ``apply``; the
1105
1268
  # engine never frames the placeholder itself.
1106
1269
  return
1107
1270
 
@@ -1114,24 +1277,19 @@ class ModalHandler(DesktopViewHandler):
1114
1277
  class TabBarHandler(DesktopViewHandler):
1115
1278
  """Bottom tab bar — a row of buttons laid out across its width."""
1116
1279
 
1117
- def create(self, props: Dict[str, Any]) -> Any:
1280
+ def build(self, props: Dict[str, Any]) -> Any:
1118
1281
  frame = tk.Frame(_master(), highlightthickness=1, bd=0, background="#f2f2f7")
1119
1282
  try:
1120
1283
  frame.configure(highlightbackground="#c6c6c8", highlightcolor="#c6c6c8")
1121
1284
  except Exception:
1122
1285
  pass
1123
1286
  frame._pn_buttons = []
1124
- self._apply(frame, props)
1125
1287
  return frame
1126
1288
 
1127
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1128
- self._apply(native_view, changed)
1129
-
1130
- def _apply(self, frame: Any, props: Dict[str, Any]) -> None:
1131
- merged = _merge_props(frame, props)
1289
+ def apply(self, frame: Any, props: Dict[str, Any]) -> None:
1290
+ merged = getattr(frame, "_pn_props", props)
1132
1291
  items: List[Dict[str, Any]] = merged.get("items") or []
1133
1292
  active = merged.get("active_tab")
1134
- on_select = merged.get("on_tab_select")
1135
1293
  for button in getattr(frame, "_pn_buttons", []):
1136
1294
  try:
1137
1295
  button.destroy()
@@ -1144,14 +1302,7 @@ class TabBarHandler(DesktopViewHandler):
1144
1302
  is_active = name == active
1145
1303
 
1146
1304
  def _make_cmd(tab_name: Any) -> Any:
1147
- def _cmd() -> None:
1148
- if callable(on_select):
1149
- try:
1150
- on_select(tab_name)
1151
- except Exception:
1152
- pass
1153
-
1154
- return _cmd
1305
+ return lambda: _fire(frame, "on_tab_select", tab_name)
1155
1306
 
1156
1307
  button = tk.Button(
1157
1308
  frame,
@@ -1200,35 +1351,23 @@ class TabBarHandler(DesktopViewHandler):
1200
1351
 
1201
1352
 
1202
1353
  class PickerHandler(DesktopViewHandler):
1203
- def create(self, props: Dict[str, Any]) -> Any:
1354
+ def build(self, props: Dict[str, Any]) -> Any:
1204
1355
  combo = ttk.Combobox(_master(), state="readonly")
1205
- self._bind(combo)
1206
- self._apply(combo, props)
1207
- return combo
1208
1356
 
1209
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1210
- self._apply(native_view, changed)
1211
-
1212
- def _bind(self, combo: Any) -> None:
1213
1357
  def _on_select(_event: Any = None) -> None:
1214
- callback = getattr(combo, "_pn_on_change", None)
1215
1358
  items = getattr(combo, "_pn_items", [])
1216
1359
  idx = combo.current()
1217
- if callable(callback) and 0 <= idx < len(items):
1218
- try:
1219
- callback(items[idx].get("value"))
1220
- except Exception:
1221
- pass
1360
+ if 0 <= idx < len(items):
1361
+ _fire(combo, "on_change", items[idx].get("value"))
1222
1362
 
1223
1363
  try:
1224
1364
  combo.bind("<<ComboboxSelected>>", _on_select)
1225
1365
  except Exception:
1226
1366
  pass
1367
+ return combo
1227
1368
 
1228
- def _apply(self, combo: Any, props: Dict[str, Any]) -> None:
1229
- merged = _merge_props(combo, props)
1230
- if "on_change" in props:
1231
- combo._pn_on_change = props["on_change"]
1369
+ def apply(self, combo: Any, props: Dict[str, Any]) -> None:
1370
+ merged = getattr(combo, "_pn_props", props)
1232
1371
  items: List[Dict[str, Any]] = merged.get("items") or []
1233
1372
  combo._pn_items = items
1234
1373
  labels = [str(item.get("label", item.get("value", ""))) for item in items]
@@ -1251,20 +1390,15 @@ class PickerHandler(DesktopViewHandler):
1251
1390
 
1252
1391
 
1253
1392
  class SegmentedControlHandler(DesktopViewHandler):
1254
- def create(self, props: Dict[str, Any]) -> Any:
1393
+ def build(self, props: Dict[str, Any]) -> Any:
1255
1394
  frame = tk.Frame(_master(), highlightthickness=0, bd=0)
1256
1395
  frame._pn_buttons = []
1257
- self._apply(frame, props)
1258
1396
  return frame
1259
1397
 
1260
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1261
- self._apply(native_view, changed)
1262
-
1263
- def _apply(self, frame: Any, props: Dict[str, Any]) -> None:
1264
- merged = _merge_props(frame, props)
1398
+ def apply(self, frame: Any, props: Dict[str, Any]) -> None:
1399
+ merged = getattr(frame, "_pn_props", props)
1265
1400
  segments: List[str] = merged.get("segments") or []
1266
1401
  selected = int(merged.get("selected_index", 0) or 0)
1267
- on_change = merged.get("on_change")
1268
1402
  tint = _tk_color(merged.get("tint_color")) or "#007aff"
1269
1403
  for button in getattr(frame, "_pn_buttons", []):
1270
1404
  try:
@@ -1276,14 +1410,7 @@ class SegmentedControlHandler(DesktopViewHandler):
1276
1410
  is_active = i == selected
1277
1411
 
1278
1412
  def _make_cmd(index: int) -> Any:
1279
- def _cmd() -> None:
1280
- if callable(on_change):
1281
- try:
1282
- on_change(index)
1283
- except Exception:
1284
- pass
1285
-
1286
- return _cmd
1413
+ return lambda: _fire(frame, "on_change", index)
1287
1414
 
1288
1415
  button = tk.Button(
1289
1416
  frame,
@@ -1329,33 +1456,20 @@ class SegmentedControlHandler(DesktopViewHandler):
1329
1456
  class DatePickerHandler(DesktopViewHandler):
1330
1457
  """Preview DatePicker — a text entry for the ISO date/time string."""
1331
1458
 
1332
- def create(self, props: Dict[str, Any]) -> Any:
1459
+ def build(self, props: Dict[str, Any]) -> Any:
1333
1460
  entry = tk.Entry(_master(), highlightthickness=1, bd=0)
1334
- self._bind(entry)
1335
- self._apply(entry, props)
1336
- return entry
1337
-
1338
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1339
- self._apply(native_view, changed)
1340
1461
 
1341
- def _bind(self, entry: Any) -> None:
1342
1462
  def _on_key(_event: Any = None) -> None:
1343
- callback = getattr(entry, "_pn_on_change", None)
1344
- if callable(callback):
1345
- try:
1346
- callback(entry.get())
1347
- except Exception:
1348
- pass
1463
+ _fire(entry, "on_change", entry.get())
1349
1464
 
1350
1465
  try:
1351
1466
  entry.bind("<KeyRelease>", _on_key)
1352
1467
  except Exception:
1353
1468
  pass
1469
+ return entry
1354
1470
 
1355
- def _apply(self, entry: Any, props: Dict[str, Any]) -> None:
1356
- merged = _merge_props(entry, props)
1357
- if "on_change" in props:
1358
- entry._pn_on_change = props["on_change"]
1471
+ def apply(self, entry: Any, props: Dict[str, Any]) -> None:
1472
+ merged = getattr(entry, "_pn_props", props)
1359
1473
  if "enabled" in merged:
1360
1474
  try:
1361
1475
  entry.configure(state="normal" if merged.get("enabled", True) else "disabled")
@@ -1378,78 +1492,6 @@ class DatePickerHandler(DesktopViewHandler):
1378
1492
  pass
1379
1493
 
1380
1494
 
1381
- # ======================================================================
1382
- # VirtualList (FlatList / SectionList)
1383
- # ======================================================================
1384
-
1385
-
1386
- class VirtualListHandler(DesktopViewHandler):
1387
- """Preview list — eagerly mounts a bounded window of rows.
1388
-
1389
- The native iOS/Android backends recycle cells; the desktop preview
1390
- mounts up to [`_MAX_ROWS`][pythonnative.native_views.desktop.VirtualListHandler]
1391
- rows into per-row cells once its frame is known. Each cell is handed
1392
- to the ``mount_row`` callback supplied by
1393
- [`FlatList`][pythonnative.FlatList] / [`SectionList`][pythonnative.SectionList],
1394
- which mounts the row's element subtree through a nested reconciler.
1395
- """
1396
-
1397
- _MAX_ROWS = 200
1398
-
1399
- def create(self, props: Dict[str, Any]) -> Any:
1400
- frame = tk.Frame(_master(), highlightthickness=0, bd=0)
1401
- frame._pn_rows = []
1402
- frame._pn_mounted = False
1403
- self._apply(frame, props)
1404
- return frame
1405
-
1406
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1407
- was_count = (getattr(native_view, "_pn_props", {}) or {}).get("count")
1408
- self._apply(native_view, changed)
1409
- if "count" in changed and changed.get("count") != was_count:
1410
- native_view._pn_mounted = False
1411
- self._mount_rows(native_view)
1412
-
1413
- def _apply(self, frame: Any, props: Dict[str, Any]) -> Dict[str, Any]:
1414
- merged = _merge_props(frame, props)
1415
- _apply_common(frame, merged)
1416
- return merged
1417
-
1418
- def _mount_rows(self, frame: Any) -> None:
1419
- if getattr(frame, "_pn_mounted", False):
1420
- return
1421
- merged = getattr(frame, "_pn_props", {}) or {}
1422
- count = int(merged.get("count", 0) or 0)
1423
- row_height = _finite(merged.get("row_height", 0.0))
1424
- mount_row = merged.get("mount_row")
1425
- frame_w, _frame_h = getattr(frame, "_pn_size", (0.0, 0.0))
1426
- if count <= 0 or row_height <= 0 or not callable(mount_row) or frame_w <= 0:
1427
- return
1428
- for cell in getattr(frame, "_pn_rows", []):
1429
- try:
1430
- cell.destroy()
1431
- except Exception:
1432
- pass
1433
- rows: List[Any] = []
1434
- for index in range(min(count, self._MAX_ROWS)):
1435
- cell = tk.Frame(_master(), highlightthickness=0, bd=0)
1436
- cell._pn_parent = frame
1437
- cell._pn_frame = (0.0, index * row_height, frame_w, row_height)
1438
- _place(cell)
1439
- rows.append(cell)
1440
- try:
1441
- mount_row(index, cell, frame_w, row_height)
1442
- except Exception:
1443
- pass
1444
- frame._pn_rows = rows
1445
- frame._pn_mounted = True
1446
-
1447
- def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
1448
- native_view._pn_size = (max(0.0, _finite(width)), max(0.0, _finite(height)))
1449
- super().set_frame(native_view, x, y, width, height)
1450
- self._mount_rows(native_view)
1451
-
1452
-
1453
1495
  # ======================================================================
1454
1496
  # Registration
1455
1497
  # ======================================================================
@@ -1459,7 +1501,9 @@ def register_handlers(registry: Any) -> None:
1459
1501
  """Register every built-in desktop handler on ``registry``.
1460
1502
 
1461
1503
  Mirrors ``register_handlers`` in the iOS / Android backends so the
1462
- desktop registry services the same 25 element types.
1504
+ desktop registry services the same element types. Lists
1505
+ (``FlatList`` / ``SectionList``) need no handler: they are Python
1506
+ components that virtualize on top of ``ScrollView``.
1463
1507
  """
1464
1508
  flex = FlexContainerHandler()
1465
1509
  registry.register("View", flex)
@@ -1482,7 +1526,6 @@ def register_handlers(registry: Any) -> None:
1482
1526
  registry.register("Pressable", PressableHandler())
1483
1527
  registry.register("StatusBar", StatusBarHandler())
1484
1528
  registry.register("KeyboardAvoidingView", KeyboardAvoidingViewHandler())
1485
- registry.register("VirtualList", VirtualListHandler())
1486
1529
  registry.register("Picker", PickerHandler())
1487
1530
  registry.register("Checkbox", CheckboxHandler())
1488
1531
  registry.register("SegmentedControl", SegmentedControlHandler())