##// END OF EJS Templates
Merge pull request #6284 from jasongrout/patch-6...
Jonathan Frederic -
r17626:4581d8b9 merge
parent child Browse files
Show More
@@ -1,446 +1,446 b''
1 1 """Base Widget class. Allows user to create widgets in the back-end that render
2 2 in the IPython notebook front-end.
3 3 """
4 4 #-----------------------------------------------------------------------------
5 5 # Copyright (c) 2013, the IPython Development Team.
6 6 #
7 7 # Distributed under the terms of the Modified BSD License.
8 8 #
9 9 # The full license is in the file COPYING.txt, distributed with this software.
10 10 #-----------------------------------------------------------------------------
11 11
12 12 #-----------------------------------------------------------------------------
13 13 # Imports
14 14 #-----------------------------------------------------------------------------
15 15 from contextlib import contextmanager
16 16
17 17 from IPython.core.getipython import get_ipython
18 18 from IPython.kernel.comm import Comm
19 19 from IPython.config import LoggingConfigurable
20 20 from IPython.utils.traitlets import Unicode, Dict, Instance, Bool, List, Tuple, Int
21 21 from IPython.utils.py3compat import string_types
22 22
23 23 #-----------------------------------------------------------------------------
24 24 # Classes
25 25 #-----------------------------------------------------------------------------
26 26 class CallbackDispatcher(LoggingConfigurable):
27 27 """A structure for registering and running callbacks"""
28 28 callbacks = List()
29 29
30 30 def __call__(self, *args, **kwargs):
31 31 """Call all of the registered callbacks."""
32 32 value = None
33 33 for callback in self.callbacks:
34 34 try:
35 35 local_value = callback(*args, **kwargs)
36 36 except Exception as e:
37 37 ip = get_ipython()
38 38 if ip is None:
39 39 self.log.warn("Exception in callback %s: %s", callback, e, exc_info=True)
40 40 else:
41 41 ip.showtraceback()
42 42 else:
43 43 value = local_value if local_value is not None else value
44 44 return value
45 45
46 46 def register_callback(self, callback, remove=False):
47 47 """(Un)Register a callback
48 48
49 49 Parameters
50 50 ----------
51 51 callback: method handle
52 52 Method to be registered or unregistered.
53 53 remove=False: bool
54 54 Whether to unregister the callback."""
55 55
56 56 # (Un)Register the callback.
57 57 if remove and callback in self.callbacks:
58 58 self.callbacks.remove(callback)
59 59 elif not remove and callback not in self.callbacks:
60 60 self.callbacks.append(callback)
61 61
62 62 def _show_traceback(method):
63 63 """decorator for showing tracebacks in IPython"""
64 64 def m(self, *args, **kwargs):
65 65 try:
66 66 return(method(self, *args, **kwargs))
67 67 except Exception as e:
68 68 ip = get_ipython()
69 69 if ip is None:
70 70 self.log.warn("Exception in widget method %s: %s", method, e, exc_info=True)
71 71 else:
72 72 ip.showtraceback()
73 73 return m
74 74
75 75 class Widget(LoggingConfigurable):
76 76 #-------------------------------------------------------------------------
77 77 # Class attributes
78 78 #-------------------------------------------------------------------------
79 79 _widget_construction_callback = None
80 80 widgets = {}
81 81
82 82 @staticmethod
83 83 def on_widget_constructed(callback):
84 84 """Registers a callback to be called when a widget is constructed.
85 85
86 86 The callback must have the following signature:
87 87 callback(widget)"""
88 88 Widget._widget_construction_callback = callback
89 89
90 90 @staticmethod
91 91 def _call_widget_constructed(widget):
92 92 """Static method, called when a widget is constructed."""
93 93 if Widget._widget_construction_callback is not None and callable(Widget._widget_construction_callback):
94 94 Widget._widget_construction_callback(widget)
95 95
96 96 #-------------------------------------------------------------------------
97 97 # Traits
98 98 #-------------------------------------------------------------------------
99 99 _model_name = Unicode('WidgetModel', help="""Name of the backbone model
100 100 registered in the front-end to create and sync this widget with.""")
101 _view_name = Unicode(help="""Default view registered in the front-end
101 _view_name = Unicode('WidgetView', help="""Default view registered in the front-end
102 102 to use to represent the widget.""", sync=True)
103 103 _comm = Instance('IPython.kernel.comm.Comm')
104 104
105 105 msg_throttle = Int(3, sync=True, help="""Maximum number of msgs the
106 106 front-end can send before receiving an idle msg from the back-end.""")
107 107
108 108 keys = List()
109 109 def _keys_default(self):
110 110 return [name for name in self.traits(sync=True)]
111 111
112 112 _property_lock = Tuple((None, None))
113 113
114 114 _display_callbacks = Instance(CallbackDispatcher, ())
115 115 _msg_callbacks = Instance(CallbackDispatcher, ())
116 116
117 117 #-------------------------------------------------------------------------
118 118 # (Con/de)structor
119 119 #-------------------------------------------------------------------------
120 120 def __init__(self, **kwargs):
121 121 """Public constructor"""
122 122 super(Widget, self).__init__(**kwargs)
123 123
124 124 self.on_trait_change(self._handle_property_changed, self.keys)
125 125 Widget._call_widget_constructed(self)
126 126
127 127 def __del__(self):
128 128 """Object disposal"""
129 129 self.close()
130 130
131 131 #-------------------------------------------------------------------------
132 132 # Properties
133 133 #-------------------------------------------------------------------------
134 134
135 135 @property
136 136 def comm(self):
137 137 """Gets the Comm associated with this widget.
138 138
139 139 If a Comm doesn't exist yet, a Comm will be created automagically."""
140 140 if self._comm is None:
141 141 # Create a comm.
142 142 self._comm = Comm(target_name=self._model_name)
143 143 self._comm.on_msg(self._handle_msg)
144 144 Widget.widgets[self.model_id] = self
145 145
146 146 # first update
147 147 self.send_state()
148 148 return self._comm
149 149
150 150 @property
151 151 def model_id(self):
152 152 """Gets the model id of this widget.
153 153
154 154 If a Comm doesn't exist yet, a Comm will be created automagically."""
155 155 return self.comm.comm_id
156 156
157 157 #-------------------------------------------------------------------------
158 158 # Methods
159 159 #-------------------------------------------------------------------------
160 160
161 161 def close(self):
162 162 """Close method.
163 163
164 164 Closes the underlying comm.
165 165 When the comm is closed, all of the widget views are automatically
166 166 removed from the front-end."""
167 167 if self._comm is not None:
168 168 Widget.widgets.pop(self.model_id, None)
169 169 self._comm.close()
170 170 self._comm = None
171 171
172 172 def send_state(self, key=None):
173 173 """Sends the widget state, or a piece of it, to the front-end.
174 174
175 175 Parameters
176 176 ----------
177 177 key : unicode (optional)
178 178 A single property's name to sync with the front-end.
179 179 """
180 180 self._send({
181 181 "method" : "update",
182 182 "state" : self.get_state()
183 183 })
184 184
185 185 def get_state(self, key=None):
186 186 """Gets the widget state, or a piece of it.
187 187
188 188 Parameters
189 189 ----------
190 190 key : unicode (optional)
191 191 A single property's name to get.
192 192 """
193 193 keys = self.keys if key is None else [key]
194 194 state = {}
195 195 for k in keys:
196 196 f = self.trait_metadata(k, 'to_json')
197 197 if f is None:
198 198 f = self._trait_to_json
199 199 value = getattr(self, k)
200 200 state[k] = f(value)
201 201 return state
202 202
203 203 def send(self, content):
204 204 """Sends a custom msg to the widget model in the front-end.
205 205
206 206 Parameters
207 207 ----------
208 208 content : dict
209 209 Content of the message to send.
210 210 """
211 211 self._send({"method": "custom", "content": content})
212 212
213 213 def on_msg(self, callback, remove=False):
214 214 """(Un)Register a custom msg receive callback.
215 215
216 216 Parameters
217 217 ----------
218 218 callback: callable
219 219 callback will be passed two arguments when a message arrives::
220 220
221 221 callback(widget, content)
222 222
223 223 remove: bool
224 224 True if the callback should be unregistered."""
225 225 self._msg_callbacks.register_callback(callback, remove=remove)
226 226
227 227 def on_displayed(self, callback, remove=False):
228 228 """(Un)Register a widget displayed callback.
229 229
230 230 Parameters
231 231 ----------
232 232 callback: method handler
233 233 Must have a signature of::
234 234
235 235 callback(widget, **kwargs)
236 236
237 237 kwargs from display are passed through without modification.
238 238 remove: bool
239 239 True if the callback should be unregistered."""
240 240 self._display_callbacks.register_callback(callback, remove=remove)
241 241
242 242 #-------------------------------------------------------------------------
243 243 # Support methods
244 244 #-------------------------------------------------------------------------
245 245 @contextmanager
246 246 def _lock_property(self, key, value):
247 247 """Lock a property-value pair.
248 248
249 249 NOTE: This, in addition to the single lock for all state changes, is
250 250 flawed. In the future we may want to look into buffering state changes
251 251 back to the front-end."""
252 252 self._property_lock = (key, value)
253 253 try:
254 254 yield
255 255 finally:
256 256 self._property_lock = (None, None)
257 257
258 258 def _should_send_property(self, key, value):
259 259 """Check the property lock (property_lock)"""
260 260 return key != self._property_lock[0] or \
261 261 value != self._property_lock[1]
262 262
263 263 # Event handlers
264 264 @_show_traceback
265 265 def _handle_msg(self, msg):
266 266 """Called when a msg is received from the front-end"""
267 267 data = msg['content']['data']
268 268 method = data['method']
269 269 if not method in ['backbone', 'custom']:
270 270 self.log.error('Unknown front-end to back-end widget msg with method "%s"' % method)
271 271
272 272 # Handle backbone sync methods CREATE, PATCH, and UPDATE all in one.
273 273 if method == 'backbone' and 'sync_data' in data:
274 274 sync_data = data['sync_data']
275 275 self._handle_receive_state(sync_data) # handles all methods
276 276
277 277 # Handle a custom msg from the front-end
278 278 elif method == 'custom':
279 279 if 'content' in data:
280 280 self._handle_custom_msg(data['content'])
281 281
282 282 def _handle_receive_state(self, sync_data):
283 283 """Called when a state is received from the front-end."""
284 284 for name in self.keys:
285 285 if name in sync_data:
286 286 f = self.trait_metadata(name, 'from_json')
287 287 if f is None:
288 288 f = self._trait_from_json
289 289 value = f(sync_data[name])
290 290 with self._lock_property(name, value):
291 291 setattr(self, name, value)
292 292
293 293 def _handle_custom_msg(self, content):
294 294 """Called when a custom msg is received."""
295 295 self._msg_callbacks(self, content)
296 296
297 297 def _handle_property_changed(self, name, old, new):
298 298 """Called when a property has been changed."""
299 299 # Make sure this isn't information that the front-end just sent us.
300 300 if self._should_send_property(name, new):
301 301 # Send new state to front-end
302 302 self.send_state(key=name)
303 303
304 304 def _handle_displayed(self, **kwargs):
305 305 """Called when a view has been displayed for this widget instance"""
306 306 self._display_callbacks(self, **kwargs)
307 307
308 308 def _trait_to_json(self, x):
309 309 """Convert a trait value to json
310 310
311 311 Traverse lists/tuples and dicts and serialize their values as well.
312 312 Replace any widgets with their model_id
313 313 """
314 314 if isinstance(x, dict):
315 315 return {k: self._trait_to_json(v) for k, v in x.items()}
316 316 elif isinstance(x, (list, tuple)):
317 317 return [self._trait_to_json(v) for v in x]
318 318 elif isinstance(x, Widget):
319 319 return "IPY_MODEL_" + x.model_id
320 320 else:
321 321 return x # Value must be JSON-able
322 322
323 323 def _trait_from_json(self, x):
324 324 """Convert json values to objects
325 325
326 326 Replace any strings representing valid model id values to Widget references.
327 327 """
328 328 if isinstance(x, dict):
329 329 return {k: self._trait_from_json(v) for k, v in x.items()}
330 330 elif isinstance(x, (list, tuple)):
331 331 return [self._trait_from_json(v) for v in x]
332 332 elif isinstance(x, string_types) and x.startswith('IPY_MODEL_') and x[10:] in Widget.widgets:
333 333 # we want to support having child widgets at any level in a hierarchy
334 334 # trusting that a widget UUID will not appear out in the wild
335 335 return Widget.widgets[x]
336 336 else:
337 337 return x
338 338
339 339 def _ipython_display_(self, **kwargs):
340 340 """Called when `IPython.display.display` is called on the widget."""
341 341 # Show view. By sending a display message, the comm is opened and the
342 342 # initial state is sent.
343 343 self._send({"method": "display"})
344 344 self._handle_displayed(**kwargs)
345 345
346 346 def _send(self, msg):
347 347 """Sends a message to the model in the front-end."""
348 348 self.comm.send(msg)
349 349
350 350
351 351 class DOMWidget(Widget):
352 352 visible = Bool(True, help="Whether the widget is visible.", sync=True)
353 353 _css = List(sync=True) # Internal CSS property list: (selector, key, value)
354 354
355 355 def get_css(self, key, selector=""):
356 356 """Get a CSS property of the widget.
357 357
358 358 Note: This function does not actually request the CSS from the
359 359 front-end; Only properties that have been set with set_css can be read.
360 360
361 361 Parameters
362 362 ----------
363 363 key: unicode
364 364 CSS key
365 365 selector: unicode (optional)
366 366 JQuery selector used when the CSS key/value was set.
367 367 """
368 368 if selector in self._css and key in self._css[selector]:
369 369 return self._css[selector][key]
370 370 else:
371 371 return None
372 372
373 373 def set_css(self, dict_or_key, value=None, selector=''):
374 374 """Set one or more CSS properties of the widget.
375 375
376 376 This function has two signatures:
377 377 - set_css(css_dict, selector='')
378 378 - set_css(key, value, selector='')
379 379
380 380 Parameters
381 381 ----------
382 382 css_dict : dict
383 383 CSS key/value pairs to apply
384 384 key: unicode
385 385 CSS key
386 386 value:
387 387 CSS value
388 388 selector: unicode (optional, kwarg only)
389 389 JQuery selector to use to apply the CSS key/value. If no selector
390 390 is provided, an empty selector is used. An empty selector makes the
391 391 front-end try to apply the css to the top-level element.
392 392 """
393 393 if value is None:
394 394 css_dict = dict_or_key
395 395 else:
396 396 css_dict = {dict_or_key: value}
397 397
398 398 for (key, value) in css_dict.items():
399 399 # First remove the selector/key pair from the css list if it exists.
400 400 # Then add the selector/key pair and new value to the bottom of the
401 401 # list.
402 402 self._css = [x for x in self._css if not (x[0]==selector and x[1]==key)]
403 403 self._css += [(selector, key, value)]
404 404 self.send_state('_css')
405 405
406 406 def add_class(self, class_names, selector=""):
407 407 """Add class[es] to a DOM element.
408 408
409 409 Parameters
410 410 ----------
411 411 class_names: unicode or list
412 412 Class name(s) to add to the DOM element(s).
413 413 selector: unicode (optional)
414 414 JQuery selector to select the DOM element(s) that the class(es) will
415 415 be added to.
416 416 """
417 417 class_list = class_names
418 418 if isinstance(class_list, (list, tuple)):
419 419 class_list = ' '.join(class_list)
420 420
421 421 self.send({
422 422 "msg_type" : "add_class",
423 423 "class_list" : class_list,
424 424 "selector" : selector
425 425 })
426 426
427 427 def remove_class(self, class_names, selector=""):
428 428 """Remove class[es] from a DOM element.
429 429
430 430 Parameters
431 431 ----------
432 432 class_names: unicode or list
433 433 Class name(s) to remove from the DOM element(s).
434 434 selector: unicode (optional)
435 435 JQuery selector to select the DOM element(s) that the class(es) will
436 436 be removed from.
437 437 """
438 438 class_list = class_names
439 439 if isinstance(class_list, (list, tuple)):
440 440 class_list = ' '.join(class_list)
441 441
442 442 self.send({
443 443 "msg_type" : "remove_class",
444 444 "class_list" : class_list,
445 445 "selector" : selector,
446 446 })
General Comments 0
You need to be logged in to leave comments. Login now