##// END OF EJS Templates
Document in widget packing that vaues must be JSON-able.
Jonathan Frederic -
Show More
@@ -1,437 +1,437 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 import inspect
17 17 import types
18 18
19 19 from IPython.kernel.comm import Comm
20 20 from IPython.config import LoggingConfigurable
21 21 from IPython.utils.traitlets import Unicode, Dict, Instance, Bool
22 22 from IPython.utils.py3compat import string_types
23 23
24 24 #-----------------------------------------------------------------------------
25 25 # Classes
26 26 #-----------------------------------------------------------------------------
27 27 class Widget(LoggingConfigurable):
28 28
29 29 #-------------------------------------------------------------------------
30 30 # Class attributes
31 31 #-------------------------------------------------------------------------
32 32 widget_construction_callback = None
33 33 widgets = {}
34 34
35 35 def on_widget_constructed(callback):
36 36 """Registers a callback to be called when a widget is constructed.
37 37
38 38 The callback must have the following signature:
39 39 callback(widget)"""
40 40 Widget.widget_construction_callback = callback
41 41
42 42 def _call_widget_constructed(widget):
43 43 """Class method, called when a widget is constructed."""
44 44 if Widget.widget_construction_callback is not None and callable(Widget.widget_construction_callback):
45 45 Widget.widget_construction_callback(widget)
46 46
47 47 #-------------------------------------------------------------------------
48 48 # Traits
49 49 #-------------------------------------------------------------------------
50 50 model_name = Unicode('WidgetModel', help="""Name of the backbone model
51 51 registered in the front-end to create and sync this widget with.""")
52 52 view_name = Unicode(help="""Default view registered in the front-end
53 53 to use to represent the widget.""", sync=True)
54 54 _comm = Instance('IPython.kernel.comm.Comm')
55 55
56 56 #-------------------------------------------------------------------------
57 57 # (Con/de)structor
58 58 #-------------------------------------------------------------------------
59 59 def __init__(self, **kwargs):
60 60 """Public constructor"""
61 61 self.closed = False
62 62 self._property_lock = (None, None)
63 63 self._display_callbacks = []
64 64 self._msg_callbacks = []
65 65 self._keys = None
66 66 super(Widget, self).__init__(**kwargs)
67 67
68 68 self.on_trait_change(self._handle_property_changed, self.keys)
69 69 Widget._call_widget_constructed(self)
70 70
71 71 def __del__(self):
72 72 """Object disposal"""
73 73 self.close()
74 74
75 75 #-------------------------------------------------------------------------
76 76 # Properties
77 77 #-------------------------------------------------------------------------
78 78 @property
79 79 def keys(self):
80 80 """Gets a list of the traitlets that should be synced with the front-end."""
81 81 if self._keys is None:
82 82 self._keys = []
83 83 for trait_name in self.trait_names():
84 84 if self.trait_metadata(trait_name, 'sync'):
85 85 self._keys.append(trait_name)
86 86 return self._keys
87 87
88 88 @property
89 89 def comm(self):
90 90 """Gets the Comm associated with this widget.
91 91
92 92 If a Comm doesn't exist yet, a Comm will be created automagically."""
93 93 if self._comm is None:
94 94 # Create a comm.
95 95 self._comm = Comm(target_name=self.model_name)
96 96 self._comm.on_msg(self._handle_msg)
97 97 self._comm.on_close(self._close)
98 98 Widget.widgets[self.model_id] = self
99 99
100 100 # first update
101 101 self.send_state()
102 102 return self._comm
103 103
104 104 @property
105 105 def model_id(self):
106 106 """Gets the model id of this widget.
107 107
108 108 If a Comm doesn't exist yet, a Comm will be created automagically."""
109 109 return self.comm.comm_id
110 110
111 111 #-------------------------------------------------------------------------
112 112 # Methods
113 113 #-------------------------------------------------------------------------
114 114 def close(self):
115 115 """Close method.
116 116
117 117 Closes the widget which closes the underlying comm.
118 118 When the comm is closed, all of the widget views are automatically
119 119 removed from the front-end."""
120 120 if not self.closed:
121 121 self._comm.close()
122 122 self._close()
123 123
124 124 def send_state(self, key=None):
125 125 """Sends the widget state, or a piece of it, to the front-end.
126 126
127 127 Parameters
128 128 ----------
129 129 key : unicode (optional)
130 130 A single property's name to sync with the front-end.
131 131 """
132 132 self._send({
133 133 "method" : "update",
134 134 "state" : self.get_state()
135 135 })
136 136
137 137 def get_state(self, key=None):
138 138 """Gets the widget state, or a piece of it.
139 139
140 140 Parameters
141 141 ----------
142 142 key : unicode (optional)
143 143 A single property's name to get.
144 144 """
145 145 keys = self.keys if key is None else [key]
146 146 return {k: self._pack_widgets(getattr(self, k)) for k in keys}
147 147
148 148 def send(self, content):
149 149 """Sends a custom msg to the widget model in the front-end.
150 150
151 151 Parameters
152 152 ----------
153 153 content : dict
154 154 Content of the message to send.
155 155 """
156 156 self._send({"method": "custom", "content": content})
157 157
158 158 def on_msg(self, callback, remove=False):
159 159 """(Un)Register a custom msg recieve callback.
160 160
161 161 Parameters
162 162 ----------
163 163 callback: method handler
164 164 Can have a signature of:
165 165 - callback(content)
166 166 - callback(sender, content)
167 167 remove: bool
168 168 True if the callback should be unregistered."""
169 169 if remove and callback in self._msg_callbacks:
170 170 self._msg_callbacks.remove(callback)
171 171 elif not remove and not callback in self._msg_callbacks:
172 172 if callable(callback):
173 173 argspec = inspect.getargspec(callback)
174 174 nargs = len(argspec[0])
175 175
176 176 # Bound methods have an additional 'self' argument
177 177 if isinstance(callback, types.MethodType):
178 178 nargs -= 1
179 179
180 180 # Call the callback
181 181 if nargs == 1:
182 182 self._msg_callbacks.append(lambda sender, content: callback(content))
183 183 elif nargs == 2:
184 184 self._msg_callbacks.append(callback)
185 185 else:
186 186 raise TypeError('Widget msg callback must ' \
187 187 'accept 1 or 2 arguments, not %d.' % nargs)
188 188 else:
189 189 raise Exception('Callback must be callable.')
190 190
191 191 def on_displayed(self, callback, remove=False):
192 192 """(Un)Register a widget displayed callback.
193 193
194 194 Parameters
195 195 ----------
196 196 callback: method handler
197 197 Can have a signature of:
198 198 - callback(sender, **kwargs)
199 199 kwargs from display call passed through without modification.
200 200 remove: bool
201 201 True if the callback should be unregistered."""
202 202 if remove and callback in self._display_callbacks:
203 203 self._display_callbacks.remove(callback)
204 204 elif not remove and not callback in self._display_callbacks:
205 205 if callable(handler):
206 206 self._display_callbacks.append(callback)
207 207 else:
208 208 raise Exception('Callback must be callable.')
209 209
210 210 #-------------------------------------------------------------------------
211 211 # Support methods
212 212 #-------------------------------------------------------------------------
213 213 @contextmanager
214 214 def _property_lock(self, key, value):
215 215 """Lock a property-value pair.
216 216
217 217 NOTE: This, in addition to the single lock for all state changes, is
218 218 flawed. In the future we may want to look into buffering state changes
219 219 back to the front-end."""
220 220 self._property_lock = (key, value)
221 221 try:
222 222 yield
223 223 finally:
224 224 self._property_lock = (None, None)
225 225
226 226 def _should_send_property(self, key, value):
227 227 """Check the property lock (property_lock)"""
228 228 return key != self._property_lock[0] or \
229 229 value != self._property_lock[1]
230 230
231 231 def _close(self):
232 232 """Unsafe close"""
233 233 del Widget.widgets[self.model_id]
234 234 self._comm = None
235 235 self.closed = True
236 236
237 237 # Event handlers
238 238 def _handle_msg(self, msg):
239 239 """Called when a msg is received from the front-end"""
240 240 data = msg['content']['data']
241 241 method = data['method']
242 242 if not method in ['backbone', 'custom']:
243 243 self.log.error('Unknown front-end to back-end widget msg with method "%s"' % method)
244 244
245 245 # Handle backbone sync methods CREATE, PATCH, and UPDATE all in one.
246 246 if method == 'backbone' and 'sync_data' in data:
247 247 sync_data = data['sync_data']
248 248 self._handle_receive_state(sync_data) # handles all methods
249 249
250 250 # Handle a custom msg from the front-end
251 251 elif method == 'custom':
252 252 if 'content' in data:
253 253 self._handle_custom_msg(data['content'])
254 254
255 255 def _handle_receive_state(self, sync_data):
256 256 """Called when a state is received from the front-end."""
257 257 for name in self.keys:
258 258 if name in sync_data:
259 259 value = self._unpack_widgets(sync_data[name])
260 260 with self._property_lock(name, value):
261 261 setattr(self, name, value)
262 262
263 263 def _handle_custom_msg(self, content):
264 264 """Called when a custom msg is received."""
265 265 for handler in self._msg_callbacks:
266 266 handler(self, content)
267 267
268 268 def _handle_property_changed(self, name, old, new):
269 269 """Called when a property has been changed."""
270 270 # Make sure this isn't information that the front-end just sent us.
271 271 if self._should_send_property(name, new):
272 272 # Send new state to front-end
273 273 self.send_state(key=name)
274 274
275 275 def _handle_displayed(self, **kwargs):
276 276 """Called when a view has been displayed for this widget instance"""
277 277 for handler in self._display_callbacks:
278 278 handler(self, **kwargs)
279 279
280 280 def _pack_widgets(self, x):
281 281 """Recursively converts all widget instances to model id strings.
282 282
283 283 Children widgets will be stored and transmitted to the front-end by
284 their model ids."""
284 their model ids. Return value must be JSON-able."""
285 285 if isinstance(x, dict):
286 286 return {k: self._pack_widgets(v) for k, v in x.items()}
287 287 elif isinstance(x, list):
288 288 return [self._pack_widgets(v) for v in x]
289 289 elif isinstance(x, Widget):
290 290 return x.model_id
291 291 else:
292 return x
292 return x # Value must be JSON-able
293 293
294 294 def _unpack_widgets(self, x):
295 295 """Recursively converts all model id strings to widget instances.
296 296
297 297 Children widgets will be stored and transmitted to the front-end by
298 298 their model ids."""
299 299 if isinstance(x, dict):
300 300 return {k: self._unpack_widgets(v) for k, v in x.items()}
301 301 elif isinstance(x, list):
302 302 return [self._unpack_widgets(v) for v in x]
303 303 elif isinstance(x, string_types):
304 304 return x if x not in Widget.widgets else Widget.widgets[x]
305 305 else:
306 306 return x
307 307
308 308 def _ipython_display_(self, **kwargs):
309 309 """Called when `IPython.display.display` is called on the widget."""
310 310 # Show view. By sending a display message, the comm is opened and the
311 311 # initial state is sent.
312 312 self._send({"method": "display"})
313 313 self._handle_displayed(**kwargs)
314 314
315 315 def _send(self, msg):
316 316 """Sends a message to the model in the front-end."""
317 317 self.comm.send(msg)
318 318
319 319
320 320 class DOMWidget(Widget):
321 321 visible = Bool(True, help="Whether or not the widget is visible.", sync=True)
322 322 _css = Dict(sync=True) # Internal CSS property dict
323 323
324 324 def get_css(self, key, selector=""):
325 325 """Get a CSS property of the widget.
326 326
327 327 Note: This function does not actually request the CSS from the
328 328 front-end; Only properties that have been set with set_css can be read.
329 329
330 330 Parameters
331 331 ----------
332 332 key: unicode
333 333 CSS key
334 334 selector: unicode (optional)
335 335 JQuery selector used when the CSS key/value was set.
336 336 """
337 337 if selector in self._css and key in self._css[selector]:
338 338 return self._css[selector][key]
339 339 else:
340 340 return None
341 341
342 342 def set_css(self, *args, **kwargs):
343 343 """Set one or more CSS properties of the widget.
344 344
345 345 This function has two signatures:
346 346 - set_css(css_dict, selector='')
347 347 - set_css(key, value, selector='')
348 348
349 349 Parameters
350 350 ----------
351 351 css_dict : dict
352 352 CSS key/value pairs to apply
353 353 key: unicode
354 354 CSS key
355 355 value
356 356 CSS value
357 357 selector: unicode (optional)
358 358 JQuery selector to use to apply the CSS key/value. If no selector
359 359 is provided, an empty selector is used. An empty selector makes the
360 360 front-end try to apply the css to a default element. The default
361 361 element is an attribute unique to each view, which is a DOM element
362 362 of the view that should be styled with common CSS (see
363 363 `$el_to_style` in the Javascript code).
364 364 """
365 365 selector = kwargs.get('selector', '')
366 366 if not selector in self._css:
367 367 self._css[selector] = {}
368 368
369 369 # Signature 1: set_css(css_dict, selector='')
370 370 if len(args) == 1:
371 371 if isinstance(args[0], dict):
372 372 for (key, value) in args[0].items():
373 373 if not (key in self._css[selector] and value == self._css[selector][key]):
374 374 self._css[selector][key] = value
375 375 self.send_state('_css')
376 376 else:
377 377 raise Exception('css_dict must be a dict.')
378 378
379 379 # Signature 2: set_css(key, value, selector='')
380 380 elif len(args) == 2 or len(args) == 3:
381 381
382 382 # Selector can be a positional arg if it's the 3rd value
383 383 if len(args) == 3:
384 384 selector = args[2]
385 385 if selector not in self._css:
386 386 self._css[selector] = {}
387 387
388 388 # Only update the property if it has changed.
389 389 key = args[0]
390 390 value = args[1]
391 391 if not (key in self._css[selector] and value == self._css[selector][key]):
392 392 self._css[selector][key] = value
393 393 self.send_state('_css') # Send new state to client.
394 394 else:
395 395 raise Exception('set_css only accepts 1-3 arguments')
396 396
397 397 def add_class(self, class_names, selector=""):
398 398 """Add class[es] to a DOM element.
399 399
400 400 Parameters
401 401 ----------
402 402 class_names: unicode or list
403 403 Class name(s) to add to the DOM element(s).
404 404 selector: unicode (optional)
405 405 JQuery selector to select the DOM element(s) that the class(es) will
406 406 be added to.
407 407 """
408 408 class_list = class_names
409 409 if isinstance(class_list, list):
410 410 class_list = ' '.join(class_list)
411 411
412 412 self.send({
413 413 "msg_type" : "add_class",
414 414 "class_list" : class_list,
415 415 "selector" : selector
416 416 })
417 417
418 418 def remove_class(self, class_names, selector=""):
419 419 """Remove class[es] from a DOM element.
420 420
421 421 Parameters
422 422 ----------
423 423 class_names: unicode or list
424 424 Class name(s) to remove from the DOM element(s).
425 425 selector: unicode (optional)
426 426 JQuery selector to select the DOM element(s) that the class(es) will
427 427 be removed from.
428 428 """
429 429 class_list = class_names
430 430 if isinstance(class_list, list):
431 431 class_list = ' '.join(class_list)
432 432
433 433 self.send({
434 434 "msg_type" : "remove_class",
435 435 "class_list" : class_list,
436 436 "selector" : selector,
437 437 })
General Comments 0
You need to be logged in to leave comments. Login now