pythonnative 0.21.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.
pythonnative/events.py ADDED
@@ -0,0 +1,210 @@
1
+ """Tag-based event routing between native views and Python callbacks.
2
+
3
+ Before the batched-commit overhaul, every event prop (``on_click``,
4
+ ``on_change``, …) was wired by storing the Python callable on (or next
5
+ to) the native view, and every re-render re-pushed fresh closures across
6
+ the bridge. This module replaces that with a single dispatch channel:
7
+
8
+ - The reconciler strips callable props out of the payload sent to
9
+ native handlers and registers them here, keyed by ``(tag, name)``.
10
+ - Handlers wire their platform listener **once** at view creation; the
11
+ listener calls [`dispatch_event`][pythonnative.events.dispatch_event]
12
+ with the view's tag and the event name.
13
+ - Re-renders only mutate this Python-side registry — no native call is
14
+ made when just a callback identity changes.
15
+
16
+ The set of event names present on an element is forwarded to handlers
17
+ under the [`EVENTS_PROP`][pythonnative.events.EVENTS_PROP] key (a
18
+ ``frozenset``), so handlers that wire expensive listeners (scroll
19
+ delegates, gesture recognizers) can do so conditionally. Dispatching an
20
+ event nobody listens to is a cheap dict miss.
21
+ """
22
+
23
+ import threading
24
+ from typing import Any, Callable, Dict, FrozenSet, Optional, Tuple
25
+
26
+ EVENTS_PROP = "_pn_events"
27
+ """Prop key carrying the ``frozenset`` of event names wired on an element."""
28
+
29
+ GESTURES_PROP = "gestures"
30
+ """Prop key carrying gesture descriptors (see ``pythonnative.gestures``)."""
31
+
32
+ # Prop dicts that may carry nested callables, mapped to the event name
33
+ # each nested key is hoisted to.
34
+ _NESTED_EVENT_PROPS: Dict[str, Dict[str, str]] = {
35
+ "refresh_control": {"on_refresh": "on_refresh"},
36
+ }
37
+
38
+
39
+ class EventRegistry:
40
+ """Process-wide map of ``(tag, event name) -> Python callback``.
41
+
42
+ Thread-safe: native backends may dispatch from the platform UI
43
+ thread while the reconciler updates registrations from the render
44
+ thread.
45
+ """
46
+
47
+ def __init__(self) -> None:
48
+ self._lock = threading.Lock()
49
+ self._callbacks: Dict[int, Dict[str, Callable[..., Any]]] = {}
50
+
51
+ def set_events(self, tag: int, events: Dict[str, Callable[..., Any]]) -> None:
52
+ """Replace every registration for ``tag`` with ``events``."""
53
+ with self._lock:
54
+ if events:
55
+ self._callbacks[tag] = dict(events)
56
+ else:
57
+ self._callbacks.pop(tag, None)
58
+
59
+ def clear(self, tag: int) -> None:
60
+ """Drop every registration for ``tag`` (called on view destroy)."""
61
+ with self._lock:
62
+ self._callbacks.pop(tag, None)
63
+
64
+ def get(self, tag: int, name: str) -> Optional[Callable[..., Any]]:
65
+ """Return the callback for ``(tag, name)``, or ``None``."""
66
+ with self._lock:
67
+ bucket = self._callbacks.get(tag)
68
+ if bucket is None:
69
+ return None
70
+ return bucket.get(name)
71
+
72
+ def has(self, tag: int, name: str) -> bool:
73
+ """Return whether a callback is registered for ``(tag, name)``."""
74
+ return self.get(tag, name) is not None
75
+
76
+ def dispatch(self, tag: int, name: str, *args: Any) -> bool:
77
+ """Invoke the callback for ``(tag, name)`` with ``args``.
78
+
79
+ Returns:
80
+ ``True`` when a callback existed and was invoked (even if
81
+ it raised — exceptions are swallowed so a buggy app
82
+ callback can't crash the platform's UI thread), ``False``
83
+ when nothing is registered.
84
+ """
85
+ callback = self.get(tag, name)
86
+ if callback is None:
87
+ return False
88
+ try:
89
+ callback(*args)
90
+ except Exception:
91
+ import traceback
92
+
93
+ traceback.print_exc()
94
+ return True
95
+
96
+ def reset(self) -> None:
97
+ """Drop every registration (test helper)."""
98
+ with self._lock:
99
+ self._callbacks.clear()
100
+
101
+
102
+ _registry = EventRegistry()
103
+
104
+
105
+ def get_event_registry() -> EventRegistry:
106
+ """Return the process-wide [`EventRegistry`][pythonnative.events.EventRegistry]."""
107
+ return _registry
108
+
109
+
110
+ def dispatch_event(tag: int, name: str, *args: Any) -> bool:
111
+ """Dispatch an event from a native view into Python.
112
+
113
+ This is the single entry point platform handlers call when a
114
+ native listener fires.
115
+
116
+ Args:
117
+ tag: The view's reconciler-assigned tag.
118
+ name: Event name — the original prop name (``"on_click"``,
119
+ ``"on_change"``, …) or a gesture channel (``"gesture:0"``).
120
+ *args: Positional arguments forwarded to the user callback,
121
+ preserving each prop's documented signature.
122
+
123
+ Returns:
124
+ Whether a callback was registered for ``(tag, name)``.
125
+ """
126
+ return _registry.dispatch(tag, name, *args)
127
+
128
+
129
+ # ======================================================================
130
+ # Prop splitting
131
+ # ======================================================================
132
+
133
+
134
+ def extract_events(props: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Callable[..., Any]]]:
135
+ """Split ``props`` into native-safe props and Python event callbacks.
136
+
137
+ Rules:
138
+
139
+ - Top-level callables named ``on_*`` become events under their prop
140
+ name and are removed from the native payload.
141
+ - ``refresh_control`` dicts have their nested ``on_refresh``
142
+ hoisted to the ``"on_refresh"`` event; the remaining keys
143
+ (``refreshing``, ``tint_color``) stay in the payload.
144
+ - ``gestures`` lists of gesture descriptors are serialized to plain
145
+ dicts (handlers wire recognizers from them) while their callbacks
146
+ are folded into per-gesture ``"gesture:<i>"`` routers.
147
+ - The resulting payload carries ``_pn_events`` — a frozenset of the
148
+ event names present — so handlers can wire listeners
149
+ conditionally and the prop differ can detect listener
150
+ addition/removal without comparing closures.
151
+
152
+ Args:
153
+ props: Raw element props (already stripped of reconciler-owned
154
+ keys).
155
+
156
+ Returns:
157
+ ``(clean_props, events)`` where ``clean_props`` contains no
158
+ callables and ``events`` maps event names to callbacks.
159
+ """
160
+ clean: Dict[str, Any] = {}
161
+ events: Dict[str, Callable[..., Any]] = {}
162
+
163
+ for key, value in props.items():
164
+ if key.startswith("on_") and callable(value):
165
+ events[key] = value
166
+ continue
167
+ nested_spec = _NESTED_EVENT_PROPS.get(key)
168
+ if nested_spec is not None and isinstance(value, dict):
169
+ remainder: Dict[str, Any] = {}
170
+ for nested_key, nested_value in value.items():
171
+ event_name = nested_spec.get(nested_key)
172
+ if event_name is not None and callable(nested_value):
173
+ events[event_name] = nested_value
174
+ else:
175
+ remainder[nested_key] = nested_value
176
+ clean[key] = remainder
177
+ continue
178
+ if key == GESTURES_PROP and value:
179
+ from .gestures import serialize_gestures
180
+
181
+ specs, gesture_events = serialize_gestures(value)
182
+ clean[key] = specs
183
+ events.update(gesture_events)
184
+ continue
185
+ clean[key] = value
186
+
187
+ if events:
188
+ clean[EVENTS_PROP] = frozenset(events)
189
+ return clean, events
190
+
191
+
192
+ def event_names(props: Dict[str, Any]) -> FrozenSet[str]:
193
+ """Return the event-name set a handler should consult for ``props``."""
194
+ names = props.get(EVENTS_PROP)
195
+ if isinstance(names, frozenset):
196
+ return names
197
+ if isinstance(names, (set, list, tuple)):
198
+ return frozenset(names)
199
+ return frozenset()
200
+
201
+
202
+ __all__ = [
203
+ "EVENTS_PROP",
204
+ "GESTURES_PROP",
205
+ "EventRegistry",
206
+ "get_event_registry",
207
+ "dispatch_event",
208
+ "extract_events",
209
+ "event_names",
210
+ ]