##// END OF EJS Templates
s/_handle_widget_constructed/_call_widget_constructed
Jonathan Frederic -
Show More
@@ -1,433 +1,433 b''
1 """Base Widget class. Allows user to create widgets in the backend that render
1 """Base Widget class. Allows user to create widgets in the backend that render
2 in the IPython notebook frontend.
2 in the IPython notebook frontend.
3 """
3 """
4 #-----------------------------------------------------------------------------
4 #-----------------------------------------------------------------------------
5 # Copyright (c) 2013, the IPython Development Team.
5 # Copyright (c) 2013, the IPython Development Team.
6 #
6 #
7 # Distributed under the terms of the Modified BSD License.
7 # Distributed under the terms of the Modified BSD License.
8 #
8 #
9 # The full license is in the file COPYING.txt, distributed with this software.
9 # The full license is in the file COPYING.txt, distributed with this software.
10 #-----------------------------------------------------------------------------
10 #-----------------------------------------------------------------------------
11
11
12 #-----------------------------------------------------------------------------
12 #-----------------------------------------------------------------------------
13 # Imports
13 # Imports
14 #-----------------------------------------------------------------------------
14 #-----------------------------------------------------------------------------
15 from copy import copy
15 from copy import copy
16 from glob import glob
16 from glob import glob
17 import uuid
17 import uuid
18 import sys
18 import sys
19 import os
19 import os
20 import inspect
20 import inspect
21 import types
21 import types
22
22
23 import IPython
23 import IPython
24 from IPython.kernel.comm import Comm
24 from IPython.kernel.comm import Comm
25 from IPython.config import LoggingConfigurable
25 from IPython.config import LoggingConfigurable
26 from IPython.utils.traitlets import Unicode, Dict, List, Instance, Bool
26 from IPython.utils.traitlets import Unicode, Dict, List, Instance, Bool
27 from IPython.display import Javascript, display
27 from IPython.display import Javascript, display
28 from IPython.utils.py3compat import string_types
28 from IPython.utils.py3compat import string_types
29
29
30 #-----------------------------------------------------------------------------
30 #-----------------------------------------------------------------------------
31 # Classes
31 # Classes
32 #-----------------------------------------------------------------------------
32 #-----------------------------------------------------------------------------
33
33
34 class Widget(LoggingConfigurable):
34 class Widget(LoggingConfigurable):
35
35
36 # Shared declarations (Class level)
36 # Shared declarations (Class level)
37 widget_construction_callback = None
37 widget_construction_callback = None
38
38
39 keys = ['view_name']
39 keys = ['view_name']
40
40
41 def on_widget_constructed(callback):
41 def on_widget_constructed(callback):
42 """Class method, registers a callback to be called when a widget is
42 """Class method, registers a callback to be called when a widget is
43 constructed. The callback must have the following signature:
43 constructed. The callback must have the following signature:
44 callback(widget)"""
44 callback(widget)"""
45 Widget.widget_construction_callback = callback
45 Widget.widget_construction_callback = callback
46
46
47 def _handle_widget_constructed(widget):
47 def _call_widget_constructed(widget):
48 """Class method, called when a widget is constructed."""
48 """Class method, called when a widget is constructed."""
49 if Widget.widget_construction_callback is not None and callable(Widget.widget_construction_callback):
49 if Widget.widget_construction_callback is not None and callable(Widget.widget_construction_callback):
50 Widget.widget_construction_callback(widget)
50 Widget.widget_construction_callback(widget)
51
51
52
52
53
53
54 # Public declarations (Instance level)
54 # Public declarations (Instance level)
55 target_name = Unicode('widget', help="""Name of the backbone model
55 target_name = Unicode('widget', help="""Name of the backbone model
56 registered in the frontend to create and sync this widget with.""")
56 registered in the frontend to create and sync this widget with.""")
57 view_name = Unicode(help="""Default view registered in the frontend
57 view_name = Unicode(help="""Default view registered in the frontend
58 to use to represent the widget.""")
58 to use to represent the widget.""")
59
59
60 # Private/protected declarations
60 # Private/protected declarations
61 # todo: change this to a context manager
61 # todo: change this to a context manager
62 _property_lock = (None, None) # Last updated (key, value) from the front-end. Prevents echo.
62 _property_lock = (None, None) # Last updated (key, value) from the front-end. Prevents echo.
63 _displayed = False
63 _displayed = False
64 _comm = Instance('IPython.kernel.comm.Comm')
64 _comm = Instance('IPython.kernel.comm.Comm')
65
65
66 def __init__(self, **kwargs):
66 def __init__(self, **kwargs):
67 """Public constructor
67 """Public constructor
68 """
68 """
69 self._display_callbacks = []
69 self._display_callbacks = []
70 self._msg_callbacks = []
70 self._msg_callbacks = []
71 super(Widget, self).__init__(**kwargs)
71 super(Widget, self).__init__(**kwargs)
72
72
73 self.on_trait_change(self._handle_property_changed, self.keys)
73 self.on_trait_change(self._handle_property_changed, self.keys)
74 Widget._handle_widget_constructed(self)
74 Widget._call_widget_constructed(self)
75
75
76 def __del__(self):
76 def __del__(self):
77 """Object disposal"""
77 """Object disposal"""
78 self.close()
78 self.close()
79
79
80
80
81 def close(self):
81 def close(self):
82 """Close method. Closes the widget which closes the underlying comm.
82 """Close method. Closes the widget which closes the underlying comm.
83 When the comm is closed, all of the widget views are automatically
83 When the comm is closed, all of the widget views are automatically
84 removed from the frontend."""
84 removed from the frontend."""
85 self._close_communication()
85 self._close_communication()
86
86
87 @property
87 @property
88 def comm(self):
88 def comm(self):
89 if self._comm is None:
89 if self._comm is None:
90 self._open_communication()
90 self._open_communication()
91 return self._comm
91 return self._comm
92
92
93 @property
93 @property
94 def model_id(self):
94 def model_id(self):
95 return self.comm.comm_id
95 return self.comm.comm_id
96
96
97 # Event handlers
97 # Event handlers
98 def _handle_msg(self, msg):
98 def _handle_msg(self, msg):
99 """Called when a msg is recieved from the frontend"""
99 """Called when a msg is recieved from the frontend"""
100 data = msg['content']['data']
100 data = msg['content']['data']
101 method = data['method']
101 method = data['method']
102
102
103 # Handle backbone sync methods CREATE, PATCH, and UPDATE all in one.
103 # Handle backbone sync methods CREATE, PATCH, and UPDATE all in one.
104 if method == 'backbone' and 'sync_data' in data:
104 if method == 'backbone' and 'sync_data' in data:
105 sync_data = data['sync_data']
105 sync_data = data['sync_data']
106 self._handle_recieve_state(sync_data) # handles all methods
106 self._handle_recieve_state(sync_data) # handles all methods
107
107
108 # Handle a custom msg from the front-end
108 # Handle a custom msg from the front-end
109 elif method == 'custom':
109 elif method == 'custom':
110 if 'custom_content' in data:
110 if 'custom_content' in data:
111 self._handle_custom_msg(data['custom_content'])
111 self._handle_custom_msg(data['custom_content'])
112
112
113 def _handle_custom_msg(self, content):
113 def _handle_custom_msg(self, content):
114 """Called when a custom msg is recieved."""
114 """Called when a custom msg is recieved."""
115 for handler in self._msg_callbacks:
115 for handler in self._msg_callbacks:
116 if callable(handler):
116 if callable(handler):
117 argspec = inspect.getargspec(handler)
117 argspec = inspect.getargspec(handler)
118 nargs = len(argspec[0])
118 nargs = len(argspec[0])
119
119
120 # Bound methods have an additional 'self' argument
120 # Bound methods have an additional 'self' argument
121 if isinstance(handler, types.MethodType):
121 if isinstance(handler, types.MethodType):
122 nargs -= 1
122 nargs -= 1
123
123
124 # Call the callback
124 # Call the callback
125 if nargs == 1:
125 if nargs == 1:
126 handler(content)
126 handler(content)
127 elif nargs == 2:
127 elif nargs == 2:
128 handler(self, content)
128 handler(self, content)
129 else:
129 else:
130 raise TypeError('Widget msg callback must ' \
130 raise TypeError('Widget msg callback must ' \
131 'accept 1 or 2 arguments, not %d.' % nargs)
131 'accept 1 or 2 arguments, not %d.' % nargs)
132
132
133
133
134 def _handle_recieve_state(self, sync_data):
134 def _handle_recieve_state(self, sync_data):
135 """Called when a state is recieved from the frontend."""
135 """Called when a state is recieved from the frontend."""
136 for name in self.keys:
136 for name in self.keys:
137 if name in sync_data:
137 if name in sync_data:
138 try:
138 try:
139 self._property_lock = (name, sync_data[name])
139 self._property_lock = (name, sync_data[name])
140 setattr(self, name, sync_data[name])
140 setattr(self, name, sync_data[name])
141 finally:
141 finally:
142 self._property_lock = (None, None)
142 self._property_lock = (None, None)
143
143
144
144
145 def _handle_property_changed(self, name, old, new):
145 def _handle_property_changed(self, name, old, new):
146 """Called when a property has been changed."""
146 """Called when a property has been changed."""
147 # Make sure this isn't information that the front-end just sent us.
147 # Make sure this isn't information that the front-end just sent us.
148 if self._property_lock[0] != name and self._property_lock[1] != new:
148 if self._property_lock[0] != name and self._property_lock[1] != new:
149 # Send new state to frontend
149 # Send new state to frontend
150 self.send_state(key=name)
150 self.send_state(key=name)
151
151
152 def _handle_displayed(self, **kwargs):
152 def _handle_displayed(self, **kwargs):
153 """Called when a view has been displayed for this widget instance
153 """Called when a view has been displayed for this widget instance
154
154
155 Parameters
155 Parameters
156 ----------
156 ----------
157 [view_name]: unicode (optional kwarg)
157 [view_name]: unicode (optional kwarg)
158 Name of the view that was displayed."""
158 Name of the view that was displayed."""
159 for handler in self._display_callbacks:
159 for handler in self._display_callbacks:
160 if callable(handler):
160 if callable(handler):
161 argspec = inspect.getargspec(handler)
161 argspec = inspect.getargspec(handler)
162 nargs = len(argspec[0])
162 nargs = len(argspec[0])
163
163
164 # Bound methods have an additional 'self' argument
164 # Bound methods have an additional 'self' argument
165 if isinstance(handler, types.MethodType):
165 if isinstance(handler, types.MethodType):
166 nargs -= 1
166 nargs -= 1
167
167
168 # Call the callback
168 # Call the callback
169 if nargs == 0:
169 if nargs == 0:
170 handler()
170 handler()
171 elif nargs == 1:
171 elif nargs == 1:
172 handler(self)
172 handler(self)
173 elif nargs == 2:
173 elif nargs == 2:
174 handler(self, kwargs.get('view_name', None))
174 handler(self, kwargs.get('view_name', None))
175 else:
175 else:
176 handler(self, **kwargs)
176 handler(self, **kwargs)
177
177
178 # Public methods
178 # Public methods
179 def send_state(self, key=None):
179 def send_state(self, key=None):
180 """Sends the widget state, or a piece of it, to the frontend.
180 """Sends the widget state, or a piece of it, to the frontend.
181
181
182 Parameters
182 Parameters
183 ----------
183 ----------
184 key : unicode (optional)
184 key : unicode (optional)
185 A single property's name to sync with the frontend.
185 A single property's name to sync with the frontend.
186 """
186 """
187 self._send({"method": "update",
187 self._send({"method": "update",
188 "state": self.get_state()})
188 "state": self.get_state()})
189
189
190 def get_state(self, key=None):
190 def get_state(self, key=None):
191 """Gets the widget state, or a piece of it.
191 """Gets the widget state, or a piece of it.
192
192
193 Parameters
193 Parameters
194 ----------
194 ----------
195 key : unicode (optional)
195 key : unicode (optional)
196 A single property's name to get.
196 A single property's name to get.
197 """
197 """
198 state = {}
198 state = {}
199
199
200 # If a key is provided, just send the state of that key.
200 # If a key is provided, just send the state of that key.
201 if key is None:
201 if key is None:
202 keys = self.keys[:]
202 keys = self.keys[:]
203 else:
203 else:
204 keys = [key]
204 keys = [key]
205 for k in keys:
205 for k in keys:
206 value = getattr(self, k)
206 value = getattr(self, k)
207
207
208 # a more elegant solution to encoding Widgets would be
208 # a more elegant solution to encoding Widgets would be
209 # to tap into the JSON encoder and teach it how to deal
209 # to tap into the JSON encoder and teach it how to deal
210 # with Widget objects, or maybe just teach the JSON
210 # with Widget objects, or maybe just teach the JSON
211 # encoder to look for a _repr_json property before giving
211 # encoder to look for a _repr_json property before giving
212 # up encoding
212 # up encoding
213 if isinstance(value, Widget):
213 if isinstance(value, Widget):
214 value = value.model_id
214 value = value.model_id
215 elif isinstance(value, list) and len(value)>0 and isinstance(value[0], Widget):
215 elif isinstance(value, list) and len(value)>0 and isinstance(value[0], Widget):
216 # assume all elements of the list are widgets
216 # assume all elements of the list are widgets
217 value = [i.model_id for i in value]
217 value = [i.model_id for i in value]
218 state[k] = value
218 state[k] = value
219 return state
219 return state
220
220
221
221
222 def send(self, content):
222 def send(self, content):
223 """Sends a custom msg to the widget model in the front-end.
223 """Sends a custom msg to the widget model in the front-end.
224
224
225 Parameters
225 Parameters
226 ----------
226 ----------
227 content : dict
227 content : dict
228 Content of the message to send.
228 Content of the message to send.
229 """
229 """
230 self._send({"method": "custom",
230 self._send({"method": "custom",
231 "custom_content": content})
231 "custom_content": content})
232
232
233
233
234 def on_msg(self, callback, remove=False):
234 def on_msg(self, callback, remove=False):
235 """Register a callback for when a custom msg is recieved from the front-end
235 """Register a callback for when a custom msg is recieved from the front-end
236
236
237 Parameters
237 Parameters
238 ----------
238 ----------
239 callback: method handler
239 callback: method handler
240 Can have a signature of:
240 Can have a signature of:
241 - callback(content)
241 - callback(content)
242 - callback(sender, content)
242 - callback(sender, content)
243 remove: bool
243 remove: bool
244 True if the callback should be unregistered."""
244 True if the callback should be unregistered."""
245 if remove and callback in self._msg_callbacks:
245 if remove and callback in self._msg_callbacks:
246 self._msg_callbacks.remove(callback)
246 self._msg_callbacks.remove(callback)
247 elif not remove and not callback in self._msg_callbacks:
247 elif not remove and not callback in self._msg_callbacks:
248 self._msg_callbacks.append(callback)
248 self._msg_callbacks.append(callback)
249
249
250
250
251 def on_displayed(self, callback, remove=False):
251 def on_displayed(self, callback, remove=False):
252 """Register a callback to be called when the widget has been displayed
252 """Register a callback to be called when the widget has been displayed
253
253
254 Parameters
254 Parameters
255 ----------
255 ----------
256 callback: method handler
256 callback: method handler
257 Can have a signature of:
257 Can have a signature of:
258 - callback()
258 - callback()
259 - callback(sender)
259 - callback(sender)
260 - callback(sender, view_name)
260 - callback(sender, view_name)
261 - callback(sender, **kwargs)
261 - callback(sender, **kwargs)
262 kwargs from display call passed through without modification.
262 kwargs from display call passed through without modification.
263 remove: bool
263 remove: bool
264 True if the callback should be unregistered."""
264 True if the callback should be unregistered."""
265 if remove and callback in self._display_callbacks:
265 if remove and callback in self._display_callbacks:
266 self._display_callbacks.remove(callback)
266 self._display_callbacks.remove(callback)
267 elif not remove and not callback in self._display_callbacks:
267 elif not remove and not callback in self._display_callbacks:
268 self._display_callbacks.append(callback)
268 self._display_callbacks.append(callback)
269
269
270
270
271 # Support methods
271 # Support methods
272 def _repr_widget_(self, **kwargs):
272 def _repr_widget_(self, **kwargs):
273 """Function that is called when `IPython.display.display` is called on
273 """Function that is called when `IPython.display.display` is called on
274 the widget.
274 the widget.
275
275
276 Parameters
276 Parameters
277 ----------
277 ----------
278 view_name: unicode (optional)
278 view_name: unicode (optional)
279 View to display in the frontend. Overrides view_name."""
279 View to display in the frontend. Overrides view_name."""
280 view_name = kwargs.get('view_name', self.view_name)
280 view_name = kwargs.get('view_name', self.view_name)
281
281
282 # Create a communication.
282 # Create a communication.
283 self._open_communication()
283 self._open_communication()
284
284
285 # Make sure model is syncronized
285 # Make sure model is syncronized
286 self.send_state()
286 self.send_state()
287
287
288 # Show view.
288 # Show view.
289 self._send({"method": "display", "view_name": view_name})
289 self._send({"method": "display", "view_name": view_name})
290 self._displayed = True
290 self._displayed = True
291 self._handle_displayed(**kwargs)
291 self._handle_displayed(**kwargs)
292
292
293
293
294 def _open_communication(self):
294 def _open_communication(self):
295 """Opens a communication with the front-end."""
295 """Opens a communication with the front-end."""
296 # Create a comm.
296 # Create a comm.
297 if self._comm is None:
297 if self._comm is None:
298 self._comm = Comm(target_name=self.target_name)
298 self._comm = Comm(target_name=self.target_name)
299 self._comm.on_msg(self._handle_msg)
299 self._comm.on_msg(self._handle_msg)
300 self._comm.on_close(self._close_communication)
300 self._comm.on_close(self._close_communication)
301
301
302 # first update
302 # first update
303 self.send_state()
303 self.send_state()
304
304
305
305
306 def _close_communication(self):
306 def _close_communication(self):
307 """Closes a communication with the front-end."""
307 """Closes a communication with the front-end."""
308 if self._comm is not None:
308 if self._comm is not None:
309 try:
309 try:
310 self._comm.close()
310 self._comm.close()
311 finally:
311 finally:
312 self._comm = None
312 self._comm = None
313
313
314
314
315 def _send(self, msg):
315 def _send(self, msg):
316 """Sends a message to the model in the front-end"""
316 """Sends a message to the model in the front-end"""
317 if self._comm is not None:
317 if self._comm is not None:
318 self._comm.send(msg)
318 self._comm.send(msg)
319 return True
319 return True
320 else:
320 else:
321 return False
321 return False
322
322
323
323
324 class DOMWidget(Widget):
324 class DOMWidget(Widget):
325 visible = Bool(True, help="Whether or not the widget is visible.")
325 visible = Bool(True, help="Whether or not the widget is visible.")
326
326
327 # Private/protected declarations
327 # Private/protected declarations
328 _css = Dict() # Internal CSS property dict
328 _css = Dict() # Internal CSS property dict
329
329
330 keys = ['visible', '_css'] + Widget.keys
330 keys = ['visible', '_css'] + Widget.keys
331
331
332 def get_css(self, key, selector=""):
332 def get_css(self, key, selector=""):
333 """Get a CSS property of the widget. Note, this function does not
333 """Get a CSS property of the widget. Note, this function does not
334 actually request the CSS from the front-end; Only properties that have
334 actually request the CSS from the front-end; Only properties that have
335 been set with set_css can be read.
335 been set with set_css can be read.
336
336
337 Parameters
337 Parameters
338 ----------
338 ----------
339 key: unicode
339 key: unicode
340 CSS key
340 CSS key
341 selector: unicode (optional)
341 selector: unicode (optional)
342 JQuery selector used when the CSS key/value was set.
342 JQuery selector used when the CSS key/value was set.
343 """
343 """
344 if selector in self._css and key in self._css[selector]:
344 if selector in self._css and key in self._css[selector]:
345 return self._css[selector][key]
345 return self._css[selector][key]
346 else:
346 else:
347 return None
347 return None
348
348
349
349
350 def set_css(self, *args, **kwargs):
350 def set_css(self, *args, **kwargs):
351 """Set one or more CSS properties of the widget (shared among all of the
351 """Set one or more CSS properties of the widget (shared among all of the
352 views). This function has two signatures:
352 views). This function has two signatures:
353 - set_css(css_dict, [selector=''])
353 - set_css(css_dict, [selector=''])
354 - set_css(key, value, [selector=''])
354 - set_css(key, value, [selector=''])
355
355
356 Parameters
356 Parameters
357 ----------
357 ----------
358 css_dict : dict
358 css_dict : dict
359 CSS key/value pairs to apply
359 CSS key/value pairs to apply
360 key: unicode
360 key: unicode
361 CSS key
361 CSS key
362 value
362 value
363 CSS value
363 CSS value
364 selector: unicode (optional)
364 selector: unicode (optional)
365 JQuery selector to use to apply the CSS key/value.
365 JQuery selector to use to apply the CSS key/value.
366 """
366 """
367 selector = kwargs.get('selector', '')
367 selector = kwargs.get('selector', '')
368
368
369 # Signature 1: set_css(css_dict, [selector=''])
369 # Signature 1: set_css(css_dict, [selector=''])
370 if len(args) == 1:
370 if len(args) == 1:
371 if isinstance(args[0], dict):
371 if isinstance(args[0], dict):
372 for (key, value) in args[0].items():
372 for (key, value) in args[0].items():
373 self.set_css(key, value, selector=selector)
373 self.set_css(key, value, selector=selector)
374 else:
374 else:
375 raise Exception('css_dict must be a dict.')
375 raise Exception('css_dict must be a dict.')
376
376
377 # Signature 2: set_css(key, value, [selector=''])
377 # Signature 2: set_css(key, value, [selector=''])
378 elif len(args) == 2 or len(args) == 3:
378 elif len(args) == 2 or len(args) == 3:
379
379
380 # Selector can be a positional arg if it's the 3rd value
380 # Selector can be a positional arg if it's the 3rd value
381 if len(args) == 3:
381 if len(args) == 3:
382 selector = args[2]
382 selector = args[2]
383 if selector not in self._css:
383 if selector not in self._css:
384 self._css[selector] = {}
384 self._css[selector] = {}
385
385
386 # Only update the property if it has changed.
386 # Only update the property if it has changed.
387 key = args[0]
387 key = args[0]
388 value = args[1]
388 value = args[1]
389 if not (key in self._css[selector] and value in self._css[selector][key]):
389 if not (key in self._css[selector] and value in self._css[selector][key]):
390 self._css[selector][key] = value
390 self._css[selector][key] = value
391 self.send_state('_css') # Send new state to client.
391 self.send_state('_css') # Send new state to client.
392 else:
392 else:
393 raise Exception('set_css only accepts 1-3 arguments')
393 raise Exception('set_css only accepts 1-3 arguments')
394
394
395
395
396 def add_class(self, class_names, selector=""):
396 def add_class(self, class_names, selector=""):
397 """Add class[es] to a DOM element
397 """Add class[es] to a DOM element
398
398
399 Parameters
399 Parameters
400 ----------
400 ----------
401 class_names: unicode or list
401 class_names: unicode or list
402 Class name(s) to add to the DOM element(s).
402 Class name(s) to add to the DOM element(s).
403 selector: unicode (optional)
403 selector: unicode (optional)
404 JQuery selector to select the DOM element(s) that the class(es) will
404 JQuery selector to select the DOM element(s) that the class(es) will
405 be added to.
405 be added to.
406 """
406 """
407 class_list = class_names
407 class_list = class_names
408 if isinstance(list, class_list):
408 if isinstance(list, class_list):
409 class_list = ' '.join(class_list)
409 class_list = ' '.join(class_list)
410
410
411 self.send({"msg_type": "add_class",
411 self.send({"msg_type": "add_class",
412 "class_list": class_list,
412 "class_list": class_list,
413 "selector": selector})
413 "selector": selector})
414
414
415
415
416 def remove_class(self, class_names, selector=""):
416 def remove_class(self, class_names, selector=""):
417 """Remove class[es] from a DOM element
417 """Remove class[es] from a DOM element
418
418
419 Parameters
419 Parameters
420 ----------
420 ----------
421 class_names: unicode or list
421 class_names: unicode or list
422 Class name(s) to remove from the DOM element(s).
422 Class name(s) to remove from the DOM element(s).
423 selector: unicode (optional)
423 selector: unicode (optional)
424 JQuery selector to select the DOM element(s) that the class(es) will
424 JQuery selector to select the DOM element(s) that the class(es) will
425 be removed from.
425 be removed from.
426 """
426 """
427 class_list = class_names
427 class_list = class_names
428 if isinstance(list, class_list):
428 if isinstance(list, class_list):
429 class_list = ' '.join(class_list)
429 class_list = ' '.join(class_list)
430
430
431 self.send({"msg_type": "remove_class",
431 self.send({"msg_type": "remove_class",
432 "class_list": class_list,
432 "class_list": class_list,
433 "selector": selector})
433 "selector": selector})
General Comments 0
You need to be logged in to leave comments. Login now