-
Notifications
You must be signed in to change notification settings - Fork 19
Expand file tree
/
Copy pathevents.py
More file actions
237 lines (189 loc) · 7.45 KB
/
events.py
File metadata and controls
237 lines (189 loc) · 7.45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
"""
Event handling for PyScript.
This module provides two complementary systems:
1. The `Event` class: A simple publish-subscribe pattern for custom events
within *your* Python code.
2. The `@when` decorator: Connects Python functions to browser DOM events,
or instances of the `Event` class, allowing you to respond to user
interactions like clicks, key presses and form submissions, or to custom
events defined in your Python code.
"""
import asyncio
import inspect
from functools import wraps
from pyscript.context import document
from pyscript.ffi import create_proxy, to_js
from pyscript.util import is_awaitable
class Event:
"""
A custom event that can notify multiple listeners when triggered.
Use this class to create your own event system within Python code.
Listeners can be either regular functions or async functions.
```python
from pyscript.events import Event
# Create a custom event.
data_loaded = Event()
# Add a listener.
def on_data_loaded(result):
print(f"Data loaded: {result}")
data_loaded.add_listener(on_data_loaded)
# Time passes.... trigger the event.
data_loaded.trigger({"data": 123})
```
"""
def __init__(self):
self._listeners = []
def trigger(self, result):
"""
Trigger the event and notify all listeners with the given `result`.
"""
for listener in self._listeners:
if is_awaitable(listener):
asyncio.create_task(listener(result))
else:
listener(result)
def add_listener(self, listener):
"""
Add a function to be called when this event is triggered.
The `listener` must be callable. It can be either a regular function
or an async function. Duplicate listeners are ignored.
"""
if not callable(listener):
msg = "Listener must be callable."
raise ValueError(msg)
if listener not in self._listeners:
self._listeners.append(listener)
def remove_listener(self, *listeners):
"""
Remove specified `listeners`. If none specified, remove all listeners.
"""
if listeners:
for listener in listeners:
try:
self._listeners.remove(listener)
except ValueError:
pass # Silently ignore listeners not in the list.
else:
self._listeners = []
def when(event_type, selector=None, **options):
"""
A decorator to handle DOM events or custom `Event` objects.
For DOM events, specify the `event_type` (e.g. `"click"`) and a `selector`
for target elements. For custom `Event` objects, just pass the `Event`
instance as the `event_type`. It's also possible to pass a list of `Event`
objects. The `selector` is required only for DOM events. It should be a
[CSS selector string](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Selectors),
`Element`, `ElementCollection`, or list of DOM elements.
For DOM events only, you can specify optional
[addEventListener options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#options):
`capture`, `once`, `passive`, or `signal`.
The decorated function can be either a regular function or an async
function. If the function accepts an argument, it will receive the event
object (for DOM events) or the Event's result (for custom events). A
function does not need to accept any arguments if it doesn't require them.
```python
from pyscript import when, display
# Handle DOM events.
@when("click", "#my-button")
def handle_click(event):
display("Button clicked!")
# Handle DOM events with options.
@when("click", "#my-button", once=True)
def handle_click_once(event):
display("Button clicked once!")
# Handle custom events.
my_event = Event()
@when(my_event)
def handle_custom(): # No event argument needed.
display("Custom event triggered!")
# Handle multiple custom events.
another_event = Event()
def another_handler():
display("Another custom event handler.")
# Attach the same handler to multiple events but not as a decorator.
when([my_event, another_event])(another_handler)
# Trigger an Event instance from a DOM event via @when.
@when("click", "#my-button")
def handle_click(event):
another_event.trigger("Button clicked!")
# Stacked decorators also work.
@when("mouseover", "#my-div")
@when(my_event)
def handle_both(event):
display("Either mouseover or custom event triggered!")
```
"""
if isinstance(event_type, str):
# This is a DOM event to handle, so check and use the selector.
if not selector:
raise ValueError("Selector required for DOM event handling.")
elements = _get_elements(selector)
if not elements:
raise ValueError(f"No elements found for selector: {selector}")
def decorator(func):
wrapper = _create_wrapper(func)
if isinstance(event_type, Event):
# Custom Event - add listener.
event_type.add_listener(wrapper)
elif isinstance(event_type, list) and all(
isinstance(t, Event) for t in event_type
):
# List of custom Events - add listener to each.
for event in event_type:
event.add_listener(wrapper)
else:
# DOM event - attach to all matched elements.
for element in elements:
element.addEventListener(
event_type,
create_proxy(wrapper),
to_js(options) if options else False,
)
return wrapper
return decorator
def _get_elements(selector):
"""
Convert various `selector` types into a list of DOM elements.
"""
from pyscript.web import Element, ElementCollection
if isinstance(selector, str):
return list(document.querySelectorAll(selector))
elif isinstance(selector, Element):
return [selector._dom_element]
elif isinstance(selector, ElementCollection):
return [el._dom_element for el in selector]
elif isinstance(selector, list):
return selector
else:
return [selector]
def _create_wrapper(func):
"""
Create an appropriate wrapper for the given function, `func`.
The wrapper handles both sync and async functions, and respects whether
the function expects to receive event arguments.
"""
# Get the original function if it's been wrapped. This avoids wrapper
# loops when stacking decorators.
original_func = func
while hasattr(original_func, "__wrapped__"):
original_func = original_func.__wrapped__
# Inspect the original function signature.
sig = inspect.signature(original_func)
accepts_args = bool(sig.parameters)
if is_awaitable(func):
if accepts_args:
async def wrapper(event):
return await func(event)
else:
async def wrapper(*args, **kwargs):
return await func()
else:
if accepts_args:
# Always create a new wrapper function to avoid issues with
# stacked decorators getting into an infinite loop.
def wrapper(event):
return func(event)
else:
def wrapper(*args, **kwargs):
return func()
return wraps(func)(wrapper)