##// END OF EJS Templates
Check for null value
Sylvain Corlay -
Show More
@@ -1,806 +1,808 b''
1 // Copyright (c) IPython Development Team.
1 // Copyright (c) IPython Development Team.
2 // Distributed under the terms of the Modified BSD License.
2 // Distributed under the terms of the Modified BSD License.
3
3
4 define(["widgets/js/manager",
4 define(["widgets/js/manager",
5 "underscore",
5 "underscore",
6 "backbone",
6 "backbone",
7 "jquery",
7 "jquery",
8 "base/js/utils",
8 "base/js/utils",
9 "base/js/namespace",
9 "base/js/namespace",
10 ], function(widgetmanager, _, Backbone, $, utils, IPython){
10 ], function(widgetmanager, _, Backbone, $, utils, IPython){
11 "use strict";
11 "use strict";
12
12
13 var unpack_models = function unpack_models(value, model) {
13 var unpack_models = function unpack_models(value, model) {
14 /**
14 /**
15 * Replace model ids with models recursively.
15 * Replace model ids with models recursively.
16 */
16 */
17 var unpacked;
17 var unpacked;
18 if ($.isArray(value)) {
18 if ($.isArray(value)) {
19 unpacked = [];
19 unpacked = [];
20 _.each(value, function(sub_value, key) {
20 _.each(value, function(sub_value, key) {
21 unpacked.push(unpack_models(sub_value, model));
21 unpacked.push(unpack_models(sub_value, model));
22 });
22 });
23 return Promise.all(unpacked);
23 return Promise.all(unpacked);
24 } else if (value instanceof Object) {
24 } else if (value instanceof Object) {
25 unpacked = {};
25 unpacked = {};
26 _.each(value, function(sub_value, key) {
26 _.each(value, function(sub_value, key) {
27 unpacked[key] = unpack_models(sub_value, model);
27 unpacked[key] = unpack_models(sub_value, model);
28 });
28 });
29 return utils.resolve_promises_dict(unpacked);
29 return utils.resolve_promises_dict(unpacked);
30 } else if (typeof value === 'string' && value.slice(0,10) === "IPY_MODEL_") {
30 } else if (typeof value === 'string' && value.slice(0,10) === "IPY_MODEL_") {
31 // get_model returns a promise already
31 // get_model returns a promise already
32 return model.widget_manager.get_model(value.slice(10, value.length));
32 return model.widget_manager.get_model(value.slice(10, value.length));
33 } else {
33 } else {
34 return Promise.resolve(value);
34 return Promise.resolve(value);
35 }
35 }
36 };
36 };
37
37
38 var WidgetModel = Backbone.Model.extend({
38 var WidgetModel = Backbone.Model.extend({
39 constructor: function (widget_manager, model_id, comm) {
39 constructor: function (widget_manager, model_id, comm) {
40 /**
40 /**
41 * Constructor
41 * Constructor
42 *
42 *
43 * Creates a WidgetModel instance.
43 * Creates a WidgetModel instance.
44 *
44 *
45 * Parameters
45 * Parameters
46 * ----------
46 * ----------
47 * widget_manager : WidgetManager instance
47 * widget_manager : WidgetManager instance
48 * model_id : string
48 * model_id : string
49 * An ID unique to this model.
49 * An ID unique to this model.
50 * comm : Comm instance (optional)
50 * comm : Comm instance (optional)
51 */
51 */
52 this.widget_manager = widget_manager;
52 this.widget_manager = widget_manager;
53 this.state_change = Promise.resolve();
53 this.state_change = Promise.resolve();
54 this._buffered_state_diff = {};
54 this._buffered_state_diff = {};
55 this.pending_msgs = 0;
55 this.pending_msgs = 0;
56 this.msg_buffer = null;
56 this.msg_buffer = null;
57 this.state_lock = null;
57 this.state_lock = null;
58 this.id = model_id;
58 this.id = model_id;
59 this.views = {};
59 this.views = {};
60 this._resolve_received_state = {};
60 this._resolve_received_state = {};
61
61
62 if (comm !== undefined) {
62 if (comm !== undefined) {
63 // Remember comm associated with the model.
63 // Remember comm associated with the model.
64 this.comm = comm;
64 this.comm = comm;
65 comm.model = this;
65 comm.model = this;
66
66
67 // Hook comm messages up to model.
67 // Hook comm messages up to model.
68 comm.on_close($.proxy(this._handle_comm_closed, this));
68 comm.on_close($.proxy(this._handle_comm_closed, this));
69 comm.on_msg($.proxy(this._handle_comm_msg, this));
69 comm.on_msg($.proxy(this._handle_comm_msg, this));
70
70
71 // Assume the comm is alive.
71 // Assume the comm is alive.
72 this.set_comm_live(true);
72 this.set_comm_live(true);
73 } else {
73 } else {
74 this.set_comm_live(false);
74 this.set_comm_live(false);
75 }
75 }
76
76
77 // Listen for the events that lead to the websocket being terminated.
77 // Listen for the events that lead to the websocket being terminated.
78 var that = this;
78 var that = this;
79 var died = function() {
79 var died = function() {
80 that.set_comm_live(false);
80 that.set_comm_live(false);
81 };
81 };
82 widget_manager.notebook.events.on('kernel_disconnected.Kernel', died);
82 widget_manager.notebook.events.on('kernel_disconnected.Kernel', died);
83 widget_manager.notebook.events.on('kernel_killed.Kernel', died);
83 widget_manager.notebook.events.on('kernel_killed.Kernel', died);
84 widget_manager.notebook.events.on('kernel_restarting.Kernel', died);
84 widget_manager.notebook.events.on('kernel_restarting.Kernel', died);
85 widget_manager.notebook.events.on('kernel_dead.Kernel', died);
85 widget_manager.notebook.events.on('kernel_dead.Kernel', died);
86
86
87 return Backbone.Model.apply(this);
87 return Backbone.Model.apply(this);
88 },
88 },
89
89
90 send: function (content, callbacks, buffers) {
90 send: function (content, callbacks, buffers) {
91 /**
91 /**
92 * Send a custom msg over the comm.
92 * Send a custom msg over the comm.
93 */
93 */
94 if (this.comm !== undefined) {
94 if (this.comm !== undefined) {
95 var data = {method: 'custom', content: content};
95 var data = {method: 'custom', content: content};
96 this.comm.send(data, callbacks, {}, buffers);
96 this.comm.send(data, callbacks, {}, buffers);
97 this.pending_msgs++;
97 this.pending_msgs++;
98 }
98 }
99 },
99 },
100
100
101 request_state: function(callbacks) {
101 request_state: function(callbacks) {
102 /**
102 /**
103 * Request a state push from the back-end.
103 * Request a state push from the back-end.
104 */
104 */
105 if (!this.comm) {
105 if (!this.comm) {
106 console.error("Could not request_state because comm doesn't exist!");
106 console.error("Could not request_state because comm doesn't exist!");
107 return;
107 return;
108 }
108 }
109
109
110 var msg_id = this.comm.send({method: 'request_state'}, callbacks || this.widget_manager.callbacks());
110 var msg_id = this.comm.send({method: 'request_state'}, callbacks || this.widget_manager.callbacks());
111
111
112 // Promise that is resolved when a state is received
112 // Promise that is resolved when a state is received
113 // from the back-end.
113 // from the back-end.
114 var that = this;
114 var that = this;
115 var received_state = new Promise(function(resolve) {
115 var received_state = new Promise(function(resolve) {
116 that._resolve_received_state[msg_id] = resolve;
116 that._resolve_received_state[msg_id] = resolve;
117 });
117 });
118 return received_state;
118 return received_state;
119 },
119 },
120
120
121 set_comm_live: function(live) {
121 set_comm_live: function(live) {
122 /**
122 /**
123 * Change the comm_live state of the model.
123 * Change the comm_live state of the model.
124 */
124 */
125 if (this.comm_live === undefined || this.comm_live != live) {
125 if (this.comm_live === undefined || this.comm_live != live) {
126 this.comm_live = live;
126 this.comm_live = live;
127 this.trigger(live ? 'comm:live' : 'comm:dead', {model: this});
127 this.trigger(live ? 'comm:live' : 'comm:dead', {model: this});
128 }
128 }
129 },
129 },
130
130
131 close: function(comm_closed) {
131 close: function(comm_closed) {
132 /**
132 /**
133 * Close model
133 * Close model
134 */
134 */
135 if (this.comm && !comm_closed) {
135 if (this.comm && !comm_closed) {
136 this.comm.close();
136 this.comm.close();
137 }
137 }
138 this.stopListening();
138 this.stopListening();
139 this.trigger('destroy', this);
139 this.trigger('destroy', this);
140 delete this.comm.model; // Delete ref so GC will collect widget model.
140 delete this.comm.model; // Delete ref so GC will collect widget model.
141 delete this.comm;
141 delete this.comm;
142 delete this.model_id; // Delete id from model so widget manager cleans up.
142 delete this.model_id; // Delete id from model so widget manager cleans up.
143 _.each(this.views, function(v, id, views) {
143 _.each(this.views, function(v, id, views) {
144 v.then(function(view) {
144 v.then(function(view) {
145 view.remove();
145 view.remove();
146 delete views[id];
146 delete views[id];
147 });
147 });
148 });
148 });
149 },
149 },
150
150
151 _handle_comm_closed: function (msg) {
151 _handle_comm_closed: function (msg) {
152 /**
152 /**
153 * Handle when a widget is closed.
153 * Handle when a widget is closed.
154 */
154 */
155 this.trigger('comm:close');
155 this.trigger('comm:close');
156 this.close(true);
156 this.close(true);
157 },
157 },
158
158
159 _handle_comm_msg: function (msg) {
159 _handle_comm_msg: function (msg) {
160 /**
160 /**
161 * Handle incoming comm msg.
161 * Handle incoming comm msg.
162 */
162 */
163 var method = msg.content.data.method;
163 var method = msg.content.data.method;
164
164
165 var that = this;
165 var that = this;
166 switch (method) {
166 switch (method) {
167 case 'update':
167 case 'update':
168 this.state_change = this.state_change
168 this.state_change = this.state_change
169 .then(function() {
169 .then(function() {
170 var state = msg.content.data.state || {};
170 var state = msg.content.data.state || {};
171 var buffer_keys = msg.content.data.buffers || [];
171 var buffer_keys = msg.content.data.buffers || [];
172 var buffers = msg.buffers || [];
172 var buffers = msg.buffers || [];
173 for (var i=0; i<buffer_keys.length; i++) {
173 for (var i=0; i<buffer_keys.length; i++) {
174 state[buffer_keys[i]] = buffers[i];
174 state[buffer_keys[i]] = buffers[i];
175 }
175 }
176
176
177 // deserialize fields that have custom deserializers
177 // deserialize fields that have custom deserializers
178 var serializers = that.constructor.serializers;
178 var serializers = that.constructor.serializers;
179 if (serializers) {
179 if (serializers) {
180 for (var k in state) {
180 for (var k in state) {
181 if (serializers[k] && serializers[k].deserialize) {
181 if (serializers[k] && serializers[k].deserialize) {
182 state[k] = (serializers[k].deserialize)(state[k], that);
182 state[k] = (serializers[k].deserialize)(state[k], that);
183 }
183 }
184 }
184 }
185 }
185 }
186 return utils.resolve_promises_dict(state);
186 return utils.resolve_promises_dict(state);
187 }).then(function(state) {
187 }).then(function(state) {
188 return that.set_state(state);
188 return that.set_state(state);
189 }).catch(utils.reject("Couldn't process update msg for model id '" + String(that.id) + "'", true))
189 }).catch(utils.reject("Couldn't process update msg for model id '" + String(that.id) + "'", true))
190 .then(function() {
190 .then(function() {
191 var parent_id = msg.parent_header.msg_id;
191 var parent_id = msg.parent_header.msg_id;
192 if (that._resolve_received_state[parent_id] !== undefined) {
192 if (that._resolve_received_state[parent_id] !== undefined) {
193 that._resolve_received_state[parent_id].call();
193 that._resolve_received_state[parent_id].call();
194 delete that._resolve_received_state[parent_id];
194 delete that._resolve_received_state[parent_id];
195 }
195 }
196 }).catch(utils.reject("Couldn't resolve state request promise", true));
196 }).catch(utils.reject("Couldn't resolve state request promise", true));
197 break;
197 break;
198 case 'custom':
198 case 'custom':
199 this.trigger('msg:custom', msg.content.data.content, msg.buffers);
199 this.trigger('msg:custom', msg.content.data.content, msg.buffers);
200 break;
200 break;
201 case 'display':
201 case 'display':
202 this.state_change = this.state_change.then(function() {
202 this.state_change = this.state_change.then(function() {
203 that.widget_manager.display_view(msg, that);
203 that.widget_manager.display_view(msg, that);
204 }).catch(utils.reject('Could not process display view msg', true));
204 }).catch(utils.reject('Could not process display view msg', true));
205 break;
205 break;
206 }
206 }
207 },
207 },
208
208
209 set_state: function (state) {
209 set_state: function (state) {
210 var that = this;
210 var that = this;
211 // Handle when a widget is updated via the python side.
211 // Handle when a widget is updated via the python side.
212 return new Promise(function(resolve, reject) {
212 return new Promise(function(resolve, reject) {
213 that.state_lock = state;
213 that.state_lock = state;
214 try {
214 try {
215 WidgetModel.__super__.set.call(that, state);
215 WidgetModel.__super__.set.call(that, state);
216 } finally {
216 } finally {
217 that.state_lock = null;
217 that.state_lock = null;
218 }
218 }
219 resolve();
219 resolve();
220 }).catch(utils.reject("Couldn't set model state", true));
220 }).catch(utils.reject("Couldn't set model state", true));
221 },
221 },
222
222
223 get_state: function() {
223 get_state: function() {
224 // Get the serializable state of the model.
224 // Get the serializable state of the model.
225 // Equivalent to Backbone.Model.toJSON()
225 // Equivalent to Backbone.Model.toJSON()
226 return _.clone(this.attributes);
226 return _.clone(this.attributes);
227 },
227 },
228
228
229 _handle_status: function (msg, callbacks) {
229 _handle_status: function (msg, callbacks) {
230 /**
230 /**
231 * Handle status msgs.
231 * Handle status msgs.
232 *
232 *
233 * execution_state : ('busy', 'idle', 'starting')
233 * execution_state : ('busy', 'idle', 'starting')
234 */
234 */
235 if (this.comm !== undefined) {
235 if (this.comm !== undefined) {
236 if (msg.content.execution_state ==='idle') {
236 if (msg.content.execution_state ==='idle') {
237 // Send buffer if this message caused another message to be
237 // Send buffer if this message caused another message to be
238 // throttled.
238 // throttled.
239 if (this.msg_buffer !== null &&
239 if (this.msg_buffer !== null &&
240 (this.get('msg_throttle') || 3) === this.pending_msgs) {
240 (this.get('msg_throttle') || 3) === this.pending_msgs) {
241 var data = {method: 'backbone', sync_method: 'update', sync_data: this.msg_buffer};
241 var data = {method: 'backbone', sync_method: 'update', sync_data: this.msg_buffer};
242 this.comm.send(data, callbacks);
242 this.comm.send(data, callbacks);
243 this.msg_buffer = null;
243 this.msg_buffer = null;
244 } else {
244 } else {
245 --this.pending_msgs;
245 --this.pending_msgs;
246 }
246 }
247 }
247 }
248 }
248 }
249 },
249 },
250
250
251 callbacks: function(view) {
251 callbacks: function(view) {
252 /**
252 /**
253 * Create msg callbacks for a comm msg.
253 * Create msg callbacks for a comm msg.
254 */
254 */
255 var callbacks = this.widget_manager.callbacks(view);
255 var callbacks = this.widget_manager.callbacks(view);
256
256
257 if (callbacks.iopub === undefined) {
257 if (callbacks.iopub === undefined) {
258 callbacks.iopub = {};
258 callbacks.iopub = {};
259 }
259 }
260
260
261 var that = this;
261 var that = this;
262 callbacks.iopub.status = function (msg) {
262 callbacks.iopub.status = function (msg) {
263 that._handle_status(msg, callbacks);
263 that._handle_status(msg, callbacks);
264 };
264 };
265 return callbacks;
265 return callbacks;
266 },
266 },
267
267
268 set: function(key, val, options) {
268 set: function(key, val, options) {
269 /**
269 /**
270 * Set a value.
270 * Set a value.
271 */
271 */
272 var return_value = WidgetModel.__super__.set.apply(this, arguments);
272 var return_value = WidgetModel.__super__.set.apply(this, arguments);
273
273
274 // Backbone only remembers the diff of the most recent set()
274 // Backbone only remembers the diff of the most recent set()
275 // operation. Calling set multiple times in a row results in a
275 // operation. Calling set multiple times in a row results in a
276 // loss of diff information. Here we keep our own running diff.
276 // loss of diff information. Here we keep our own running diff.
277 this._buffered_state_diff = $.extend(this._buffered_state_diff, this.changedAttributes() || {});
277 this._buffered_state_diff = $.extend(this._buffered_state_diff, this.changedAttributes() || {});
278 return return_value;
278 return return_value;
279 },
279 },
280
280
281 sync: function (method, model, options) {
281 sync: function (method, model, options) {
282 /**
282 /**
283 * Handle sync to the back-end. Called when a model.save() is called.
283 * Handle sync to the back-end. Called when a model.save() is called.
284 *
284 *
285 * Make sure a comm exists.
285 * Make sure a comm exists.
286
286
287 * Parameters
287 * Parameters
288 * ----------
288 * ----------
289 * method : create, update, patch, delete, read
289 * method : create, update, patch, delete, read
290 * create/update always send the full attribute set
290 * create/update always send the full attribute set
291 * patch - only send attributes listed in options.attrs, and if we are queuing
291 * patch - only send attributes listed in options.attrs, and if we are queuing
292 * up messages, combine with previous messages that have not been sent yet
292 * up messages, combine with previous messages that have not been sent yet
293 * model : the model we are syncing
293 * model : the model we are syncing
294 * will normally be the same as `this`
294 * will normally be the same as `this`
295 * options : dict
295 * options : dict
296 * the `attrs` key, if it exists, gives an {attr: value} dict that should be synced,
296 * the `attrs` key, if it exists, gives an {attr: value} dict that should be synced,
297 * otherwise, sync all attributes
297 * otherwise, sync all attributes
298 *
298 *
299 */
299 */
300 var error = options.error || function() {
300 var error = options.error || function() {
301 console.error('Backbone sync error:', arguments);
301 console.error('Backbone sync error:', arguments);
302 };
302 };
303 if (this.comm === undefined) {
303 if (this.comm === undefined) {
304 error();
304 error();
305 return false;
305 return false;
306 }
306 }
307
307
308 var attrs = (method === 'patch') ? options.attrs : model.get_state(options);
308 var attrs = (method === 'patch') ? options.attrs : model.get_state(options);
309
309
310 // the state_lock lists attributes that are currently be changed right now from a kernel message
310 // the state_lock lists attributes that are currently be changed right now from a kernel message
311 // we don't want to send these non-changes back to the kernel, so we delete them out of attrs
311 // we don't want to send these non-changes back to the kernel, so we delete them out of attrs
312 // (but we only delete them if the value hasn't changed from the value stored in the state_lock
312 // (but we only delete them if the value hasn't changed from the value stored in the state_lock
313 if (this.state_lock !== null) {
313 if (this.state_lock !== null) {
314 var keys = Object.keys(this.state_lock);
314 var keys = Object.keys(this.state_lock);
315 for (var i=0; i<keys.length; i++) {
315 for (var i=0; i<keys.length; i++) {
316 var key = keys[i];
316 var key = keys[i];
317 if (attrs[key] === this.state_lock[key]) {
317 if (attrs[key] === this.state_lock[key]) {
318 delete attrs[key];
318 delete attrs[key];
319 }
319 }
320 }
320 }
321 }
321 }
322
322
323 if (_.size(attrs) > 0) {
323 if (_.size(attrs) > 0) {
324
324
325 // If this message was sent via backbone itself, it will not
325 // If this message was sent via backbone itself, it will not
326 // have any callbacks. It's important that we create callbacks
326 // have any callbacks. It's important that we create callbacks
327 // so we can listen for status messages, etc...
327 // so we can listen for status messages, etc...
328 var callbacks = options.callbacks || this.callbacks();
328 var callbacks = options.callbacks || this.callbacks();
329
329
330 // Check throttle.
330 // Check throttle.
331 if (this.pending_msgs >= (this.get('msg_throttle') || 3)) {
331 if (this.pending_msgs >= (this.get('msg_throttle') || 3)) {
332 // The throttle has been exceeded, buffer the current msg so
332 // The throttle has been exceeded, buffer the current msg so
333 // it can be sent once the kernel has finished processing
333 // it can be sent once the kernel has finished processing
334 // some of the existing messages.
334 // some of the existing messages.
335
335
336 // Combine updates if it is a 'patch' sync, otherwise replace updates
336 // Combine updates if it is a 'patch' sync, otherwise replace updates
337 switch (method) {
337 switch (method) {
338 case 'patch':
338 case 'patch':
339 this.msg_buffer = $.extend(this.msg_buffer || {}, attrs);
339 this.msg_buffer = $.extend(this.msg_buffer || {}, attrs);
340 break;
340 break;
341 case 'update':
341 case 'update':
342 case 'create':
342 case 'create':
343 this.msg_buffer = attrs;
343 this.msg_buffer = attrs;
344 break;
344 break;
345 default:
345 default:
346 error();
346 error();
347 return false;
347 return false;
348 }
348 }
349 this.msg_buffer_callbacks = callbacks;
349 this.msg_buffer_callbacks = callbacks;
350
350
351 } else {
351 } else {
352 // We haven't exceeded the throttle, send the message like
352 // We haven't exceeded the throttle, send the message like
353 // normal.
353 // normal.
354 this.send_sync_message(attrs, callbacks);
354 this.send_sync_message(attrs, callbacks);
355 this.pending_msgs++;
355 this.pending_msgs++;
356 }
356 }
357 }
357 }
358 // Since the comm is a one-way communication, assume the message
358 // Since the comm is a one-way communication, assume the message
359 // arrived. Don't call success since we don't have a model back from the server
359 // arrived. Don't call success since we don't have a model back from the server
360 // this means we miss out on the 'sync' event.
360 // this means we miss out on the 'sync' event.
361 this._buffered_state_diff = {};
361 this._buffered_state_diff = {};
362 },
362 },
363
363
364
364
365 send_sync_message: function(attrs, callbacks) {
365 send_sync_message: function(attrs, callbacks) {
366 // prepare and send a comm message syncing attrs
366 // prepare and send a comm message syncing attrs
367 var that = this;
367 var that = this;
368 // first, build a state dictionary with key=the attribute and the value
368 // first, build a state dictionary with key=the attribute and the value
369 // being the value or the promise of the serialized value
369 // being the value or the promise of the serialized value
370 var serializers = this.constructor.serializers;
370 var serializers = this.constructor.serializers;
371 if (serializers) {
371 if (serializers) {
372 for (var k in attrs) {
372 for (var k in attrs) {
373 if (serializers[k] && serializers[k].serialize) {
373 if (serializers[k] && serializers[k].serialize) {
374 attrs[k] = (serializers[k].serialize)(attrs[k], this);
374 attrs[k] = (serializers[k].serialize)(attrs[k], this);
375 }
375 }
376 }
376 }
377 }
377 }
378 utils.resolve_promises_dict(attrs).then(function(state) {
378 utils.resolve_promises_dict(attrs).then(function(state) {
379 // get binary values, then send
379 // get binary values, then send
380 var keys = Object.keys(state);
380 var keys = Object.keys(state);
381 var buffers = [];
381 var buffers = [];
382 var buffer_keys = [];
382 var buffer_keys = [];
383 for (var i=0; i<keys.length; i++) {
383 for (var i=0; i<keys.length; i++) {
384 var key = keys[i];
384 var key = keys[i];
385 var value = state[key];
385 var value = state[key];
386 if (value.buffer instanceof ArrayBuffer
386 if (value) {
387 || value instanceof ArrayBuffer) {
387 if (value.buffer instanceof ArrayBuffer
388 buffers.push(value);
388 || value instanceof ArrayBuffer) {
389 buffer_keys.push(key);
389 buffers.push(value);
390 delete state[key];
390 buffer_keys.push(key);
391 delete state[key];
392 }
391 }
393 }
392 }
394 }
393 that.comm.send({method: 'backbone', sync_data: state, buffer_keys: buffer_keys}, callbacks, {}, buffers);
395 that.comm.send({method: 'backbone', sync_data: state, buffer_keys: buffer_keys}, callbacks, {}, buffers);
394 }).catch(function(error) {
396 }).catch(function(error) {
395 that.pending_msgs--;
397 that.pending_msgs--;
396 return (utils.reject("Couldn't send widget sync message", true))(error);
398 return (utils.reject("Couldn't send widget sync message", true))(error);
397 });
399 });
398 },
400 },
399
401
400 save_changes: function(callbacks) {
402 save_changes: function(callbacks) {
401 /**
403 /**
402 * Push this model's state to the back-end
404 * Push this model's state to the back-end
403 *
405 *
404 * This invokes a Backbone.Sync.
406 * This invokes a Backbone.Sync.
405 */
407 */
406 this.save(this._buffered_state_diff, {patch: true, callbacks: callbacks});
408 this.save(this._buffered_state_diff, {patch: true, callbacks: callbacks});
407 },
409 },
408
410
409 on_some_change: function(keys, callback, context) {
411 on_some_change: function(keys, callback, context) {
410 /**
412 /**
411 * on_some_change(["key1", "key2"], foo, context) differs from
413 * on_some_change(["key1", "key2"], foo, context) differs from
412 * on("change:key1 change:key2", foo, context).
414 * on("change:key1 change:key2", foo, context).
413 * If the widget attributes key1 and key2 are both modified,
415 * If the widget attributes key1 and key2 are both modified,
414 * the second form will result in foo being called twice
416 * the second form will result in foo being called twice
415 * while the first will call foo only once.
417 * while the first will call foo only once.
416 */
418 */
417 this.on('change', function() {
419 this.on('change', function() {
418 if (keys.some(this.hasChanged, this)) {
420 if (keys.some(this.hasChanged, this)) {
419 callback.apply(context);
421 callback.apply(context);
420 }
422 }
421 }, this);
423 }, this);
422
424
423 },
425 },
424
426
425 toJSON: function(options) {
427 toJSON: function(options) {
426 /**
428 /**
427 * Serialize the model. See the types.js deserialization function
429 * Serialize the model. See the types.js deserialization function
428 * and the kernel-side serializer/deserializer
430 * and the kernel-side serializer/deserializer
429 */
431 */
430 return "IPY_MODEL_"+this.id;
432 return "IPY_MODEL_"+this.id;
431 }
433 }
432 });
434 });
433 widgetmanager.WidgetManager.register_widget_model('WidgetModel', WidgetModel);
435 widgetmanager.WidgetManager.register_widget_model('WidgetModel', WidgetModel);
434
436
435
437
436 var WidgetView = Backbone.View.extend({
438 var WidgetView = Backbone.View.extend({
437 initialize: function(parameters) {
439 initialize: function(parameters) {
438 /**
440 /**
439 * Public constructor.
441 * Public constructor.
440 */
442 */
441 this.model.on('change',this.update,this);
443 this.model.on('change',this.update,this);
442
444
443 // Bubble the comm live events.
445 // Bubble the comm live events.
444 this.model.on('comm:live', function() {
446 this.model.on('comm:live', function() {
445 this.trigger('comm:live', this);
447 this.trigger('comm:live', this);
446 }, this);
448 }, this);
447 this.model.on('comm:dead', function() {
449 this.model.on('comm:dead', function() {
448 this.trigger('comm:dead', this);
450 this.trigger('comm:dead', this);
449 }, this);
451 }, this);
450
452
451 this.options = parameters.options;
453 this.options = parameters.options;
452 this.on('displayed', function() {
454 this.on('displayed', function() {
453 this.is_displayed = true;
455 this.is_displayed = true;
454 }, this);
456 }, this);
455 },
457 },
456
458
457 update: function(){
459 update: function(){
458 /**
460 /**
459 * Triggered on model change.
461 * Triggered on model change.
460 *
462 *
461 * Update view to be consistent with this.model
463 * Update view to be consistent with this.model
462 */
464 */
463 },
465 },
464
466
465 create_child_view: function(child_model, options) {
467 create_child_view: function(child_model, options) {
466 /**
468 /**
467 * Create and promise that resolves to a child view of a given model
469 * Create and promise that resolves to a child view of a given model
468 */
470 */
469 var that = this;
471 var that = this;
470 options = $.extend({ parent: this }, options || {});
472 options = $.extend({ parent: this }, options || {});
471 return this.model.widget_manager.create_view(child_model, options).catch(utils.reject("Couldn't create child view", true));
473 return this.model.widget_manager.create_view(child_model, options).catch(utils.reject("Couldn't create child view", true));
472 },
474 },
473
475
474 callbacks: function(){
476 callbacks: function(){
475 /**
477 /**
476 * Create msg callbacks for a comm msg.
478 * Create msg callbacks for a comm msg.
477 */
479 */
478 return this.model.callbacks(this);
480 return this.model.callbacks(this);
479 },
481 },
480
482
481 render: function(){
483 render: function(){
482 /**
484 /**
483 * Render the view.
485 * Render the view.
484 *
486 *
485 * By default, this is only called the first time the view is created
487 * By default, this is only called the first time the view is created
486 */
488 */
487 },
489 },
488
490
489 send: function (content, buffers) {
491 send: function (content, buffers) {
490 /**
492 /**
491 * Send a custom msg associated with this view.
493 * Send a custom msg associated with this view.
492 */
494 */
493 this.model.send(content, this.callbacks(), buffers);
495 this.model.send(content, this.callbacks(), buffers);
494 },
496 },
495
497
496 touch: function () {
498 touch: function () {
497 this.model.save_changes(this.callbacks());
499 this.model.save_changes(this.callbacks());
498 },
500 },
499
501
500 after_displayed: function (callback, context) {
502 after_displayed: function (callback, context) {
501 /**
503 /**
502 * Calls the callback right away is the view is already displayed
504 * Calls the callback right away is the view is already displayed
503 * otherwise, register the callback to the 'displayed' event.
505 * otherwise, register the callback to the 'displayed' event.
504 */
506 */
505 if (this.is_displayed) {
507 if (this.is_displayed) {
506 callback.apply(context);
508 callback.apply(context);
507 } else {
509 } else {
508 this.on('displayed', callback, context);
510 this.on('displayed', callback, context);
509 }
511 }
510 },
512 },
511
513
512 remove: function () {
514 remove: function () {
513 // Raise a remove event when the view is removed.
515 // Raise a remove event when the view is removed.
514 WidgetView.__super__.remove.apply(this, arguments);
516 WidgetView.__super__.remove.apply(this, arguments);
515 this.trigger('remove');
517 this.trigger('remove');
516 }
518 }
517 });
519 });
518
520
519
521
520 var DOMWidgetView = WidgetView.extend({
522 var DOMWidgetView = WidgetView.extend({
521 initialize: function (parameters) {
523 initialize: function (parameters) {
522 /**
524 /**
523 * Public constructor
525 * Public constructor
524 */
526 */
525 DOMWidgetView.__super__.initialize.apply(this, [parameters]);
527 DOMWidgetView.__super__.initialize.apply(this, [parameters]);
526 this.model.on('change:visible', this.update_visible, this);
528 this.model.on('change:visible', this.update_visible, this);
527 this.model.on('change:_css', this.update_css, this);
529 this.model.on('change:_css', this.update_css, this);
528
530
529 this.model.on('change:_dom_classes', function(model, new_classes) {
531 this.model.on('change:_dom_classes', function(model, new_classes) {
530 var old_classes = model.previous('_dom_classes');
532 var old_classes = model.previous('_dom_classes');
531 this.update_classes(old_classes, new_classes);
533 this.update_classes(old_classes, new_classes);
532 }, this);
534 }, this);
533
535
534 this.model.on('change:color', function (model, value) {
536 this.model.on('change:color', function (model, value) {
535 this.update_attr('color', value); }, this);
537 this.update_attr('color', value); }, this);
536
538
537 this.model.on('change:background_color', function (model, value) {
539 this.model.on('change:background_color', function (model, value) {
538 this.update_attr('background', value); }, this);
540 this.update_attr('background', value); }, this);
539
541
540 this.model.on('change:width', function (model, value) {
542 this.model.on('change:width', function (model, value) {
541 this.update_attr('width', value); }, this);
543 this.update_attr('width', value); }, this);
542
544
543 this.model.on('change:height', function (model, value) {
545 this.model.on('change:height', function (model, value) {
544 this.update_attr('height', value); }, this);
546 this.update_attr('height', value); }, this);
545
547
546 this.model.on('change:border_color', function (model, value) {
548 this.model.on('change:border_color', function (model, value) {
547 this.update_attr('border-color', value); }, this);
549 this.update_attr('border-color', value); }, this);
548
550
549 this.model.on('change:border_width', function (model, value) {
551 this.model.on('change:border_width', function (model, value) {
550 this.update_attr('border-width', value); }, this);
552 this.update_attr('border-width', value); }, this);
551
553
552 this.model.on('change:border_style', function (model, value) {
554 this.model.on('change:border_style', function (model, value) {
553 this.update_attr('border-style', value); }, this);
555 this.update_attr('border-style', value); }, this);
554
556
555 this.model.on('change:font_style', function (model, value) {
557 this.model.on('change:font_style', function (model, value) {
556 this.update_attr('font-style', value); }, this);
558 this.update_attr('font-style', value); }, this);
557
559
558 this.model.on('change:font_weight', function (model, value) {
560 this.model.on('change:font_weight', function (model, value) {
559 this.update_attr('font-weight', value); }, this);
561 this.update_attr('font-weight', value); }, this);
560
562
561 this.model.on('change:font_size', function (model, value) {
563 this.model.on('change:font_size', function (model, value) {
562 this.update_attr('font-size', this._default_px(value)); }, this);
564 this.update_attr('font-size', this._default_px(value)); }, this);
563
565
564 this.model.on('change:font_family', function (model, value) {
566 this.model.on('change:font_family', function (model, value) {
565 this.update_attr('font-family', value); }, this);
567 this.update_attr('font-family', value); }, this);
566
568
567 this.model.on('change:padding', function (model, value) {
569 this.model.on('change:padding', function (model, value) {
568 this.update_attr('padding', value); }, this);
570 this.update_attr('padding', value); }, this);
569
571
570 this.model.on('change:margin', function (model, value) {
572 this.model.on('change:margin', function (model, value) {
571 this.update_attr('margin', this._default_px(value)); }, this);
573 this.update_attr('margin', this._default_px(value)); }, this);
572
574
573 this.model.on('change:border_radius', function (model, value) {
575 this.model.on('change:border_radius', function (model, value) {
574 this.update_attr('border-radius', this._default_px(value)); }, this);
576 this.update_attr('border-radius', this._default_px(value)); }, this);
575
577
576 this.after_displayed(function() {
578 this.after_displayed(function() {
577 this.update_visible(this.model, this.model.get("visible"));
579 this.update_visible(this.model, this.model.get("visible"));
578 this.update_classes([], this.model.get('_dom_classes'));
580 this.update_classes([], this.model.get('_dom_classes'));
579
581
580 this.update_attr('color', this.model.get('color'));
582 this.update_attr('color', this.model.get('color'));
581 this.update_attr('background', this.model.get('background_color'));
583 this.update_attr('background', this.model.get('background_color'));
582 this.update_attr('width', this.model.get('width'));
584 this.update_attr('width', this.model.get('width'));
583 this.update_attr('height', this.model.get('height'));
585 this.update_attr('height', this.model.get('height'));
584 this.update_attr('border-color', this.model.get('border_color'));
586 this.update_attr('border-color', this.model.get('border_color'));
585 this.update_attr('border-width', this.model.get('border_width'));
587 this.update_attr('border-width', this.model.get('border_width'));
586 this.update_attr('border-style', this.model.get('border_style'));
588 this.update_attr('border-style', this.model.get('border_style'));
587 this.update_attr('font-style', this.model.get('font_style'));
589 this.update_attr('font-style', this.model.get('font_style'));
588 this.update_attr('font-weight', this.model.get('font_weight'));
590 this.update_attr('font-weight', this.model.get('font_weight'));
589 this.update_attr('font-size', this._default_px(this.model.get('font_size')));
591 this.update_attr('font-size', this._default_px(this.model.get('font_size')));
590 this.update_attr('font-family', this.model.get('font_family'));
592 this.update_attr('font-family', this.model.get('font_family'));
591 this.update_attr('padding', this.model.get('padding'));
593 this.update_attr('padding', this.model.get('padding'));
592 this.update_attr('margin', this._default_px(this.model.get('margin')));
594 this.update_attr('margin', this._default_px(this.model.get('margin')));
593 this.update_attr('border-radius', this._default_px(this.model.get('border_radius')));
595 this.update_attr('border-radius', this._default_px(this.model.get('border_radius')));
594
596
595 this.update_css(this.model, this.model.get("_css"));
597 this.update_css(this.model, this.model.get("_css"));
596 }, this);
598 }, this);
597 },
599 },
598
600
599 _default_px: function(value) {
601 _default_px: function(value) {
600 /**
602 /**
601 * Makes browser interpret a numerical string as a pixel value.
603 * Makes browser interpret a numerical string as a pixel value.
602 */
604 */
603 if (value && /^\d+\.?(\d+)?$/.test(value.trim())) {
605 if (value && /^\d+\.?(\d+)?$/.test(value.trim())) {
604 return value.trim() + 'px';
606 return value.trim() + 'px';
605 }
607 }
606 return value;
608 return value;
607 },
609 },
608
610
609 update_attr: function(name, value) {
611 update_attr: function(name, value) {
610 /**
612 /**
611 * Set a css attr of the widget view.
613 * Set a css attr of the widget view.
612 */
614 */
613 this.$el.css(name, value);
615 this.$el.css(name, value);
614 },
616 },
615
617
616 update_visible: function(model, value) {
618 update_visible: function(model, value) {
617 /**
619 /**
618 * Update visibility
620 * Update visibility
619 */
621 */
620 switch(value) {
622 switch(value) {
621 case null: // python None
623 case null: // python None
622 this.$el.show().css('visibility', 'hidden'); break;
624 this.$el.show().css('visibility', 'hidden'); break;
623 case false:
625 case false:
624 this.$el.hide(); break;
626 this.$el.hide(); break;
625 case true:
627 case true:
626 this.$el.show().css('visibility', ''); break;
628 this.$el.show().css('visibility', ''); break;
627 }
629 }
628 },
630 },
629
631
630 update_css: function (model, css) {
632 update_css: function (model, css) {
631 /**
633 /**
632 * Update the css styling of this view.
634 * Update the css styling of this view.
633 */
635 */
634 if (css === undefined) {return;}
636 if (css === undefined) {return;}
635 for (var i = 0; i < css.length; i++) {
637 for (var i = 0; i < css.length; i++) {
636 // Apply the css traits to all elements that match the selector.
638 // Apply the css traits to all elements that match the selector.
637 var selector = css[i][0];
639 var selector = css[i][0];
638 var elements = this._get_selector_element(selector);
640 var elements = this._get_selector_element(selector);
639 if (elements.length > 0) {
641 if (elements.length > 0) {
640 var trait_key = css[i][1];
642 var trait_key = css[i][1];
641 var trait_value = css[i][2];
643 var trait_value = css[i][2];
642 elements.css(trait_key ,trait_value);
644 elements.css(trait_key ,trait_value);
643 }
645 }
644 }
646 }
645 },
647 },
646
648
647 update_classes: function (old_classes, new_classes, $el) {
649 update_classes: function (old_classes, new_classes, $el) {
648 /**
650 /**
649 * Update the DOM classes applied to an element, default to this.$el.
651 * Update the DOM classes applied to an element, default to this.$el.
650 */
652 */
651 if ($el===undefined) {
653 if ($el===undefined) {
652 $el = this.$el;
654 $el = this.$el;
653 }
655 }
654 _.difference(old_classes, new_classes).map(function(c) {$el.removeClass(c);})
656 _.difference(old_classes, new_classes).map(function(c) {$el.removeClass(c);})
655 _.difference(new_classes, old_classes).map(function(c) {$el.addClass(c);})
657 _.difference(new_classes, old_classes).map(function(c) {$el.addClass(c);})
656 },
658 },
657
659
658 update_mapped_classes: function(class_map, trait_name, previous_trait_value, $el) {
660 update_mapped_classes: function(class_map, trait_name, previous_trait_value, $el) {
659 /**
661 /**
660 * Update the DOM classes applied to the widget based on a single
662 * Update the DOM classes applied to the widget based on a single
661 * trait's value.
663 * trait's value.
662 *
664 *
663 * Given a trait value classes map, this function automatically
665 * Given a trait value classes map, this function automatically
664 * handles applying the appropriate classes to the widget element
666 * handles applying the appropriate classes to the widget element
665 * and removing classes that are no longer valid.
667 * and removing classes that are no longer valid.
666 *
668 *
667 * Parameters
669 * Parameters
668 * ----------
670 * ----------
669 * class_map: dictionary
671 * class_map: dictionary
670 * Dictionary of trait values to class lists.
672 * Dictionary of trait values to class lists.
671 * Example:
673 * Example:
672 * {
674 * {
673 * success: ['alert', 'alert-success'],
675 * success: ['alert', 'alert-success'],
674 * info: ['alert', 'alert-info'],
676 * info: ['alert', 'alert-info'],
675 * warning: ['alert', 'alert-warning'],
677 * warning: ['alert', 'alert-warning'],
676 * danger: ['alert', 'alert-danger']
678 * danger: ['alert', 'alert-danger']
677 * };
679 * };
678 * trait_name: string
680 * trait_name: string
679 * Name of the trait to check the value of.
681 * Name of the trait to check the value of.
680 * previous_trait_value: optional string, default ''
682 * previous_trait_value: optional string, default ''
681 * Last trait value
683 * Last trait value
682 * $el: optional jQuery element handle, defaults to this.$el
684 * $el: optional jQuery element handle, defaults to this.$el
683 * Element that the classes are applied to.
685 * Element that the classes are applied to.
684 */
686 */
685 var key = previous_trait_value;
687 var key = previous_trait_value;
686 if (key === undefined) {
688 if (key === undefined) {
687 key = this.model.previous(trait_name);
689 key = this.model.previous(trait_name);
688 }
690 }
689 var old_classes = class_map[key] ? class_map[key] : [];
691 var old_classes = class_map[key] ? class_map[key] : [];
690 key = this.model.get(trait_name);
692 key = this.model.get(trait_name);
691 var new_classes = class_map[key] ? class_map[key] : [];
693 var new_classes = class_map[key] ? class_map[key] : [];
692
694
693 this.update_classes(old_classes, new_classes, $el || this.$el);
695 this.update_classes(old_classes, new_classes, $el || this.$el);
694 },
696 },
695
697
696 _get_selector_element: function (selector) {
698 _get_selector_element: function (selector) {
697 /**
699 /**
698 * Get the elements via the css selector.
700 * Get the elements via the css selector.
699 */
701 */
700 var elements;
702 var elements;
701 if (!selector) {
703 if (!selector) {
702 elements = this.$el;
704 elements = this.$el;
703 } else {
705 } else {
704 elements = this.$el.find(selector).addBack(selector);
706 elements = this.$el.find(selector).addBack(selector);
705 }
707 }
706 return elements;
708 return elements;
707 },
709 },
708
710
709 typeset: function(element, text){
711 typeset: function(element, text){
710 utils.typeset.apply(null, arguments);
712 utils.typeset.apply(null, arguments);
711 },
713 },
712 });
714 });
713
715
714
716
715 var ViewList = function(create_view, remove_view, context) {
717 var ViewList = function(create_view, remove_view, context) {
716 /**
718 /**
717 * - create_view and remove_view are default functions called when adding or removing views
719 * - create_view and remove_view are default functions called when adding or removing views
718 * - create_view takes a model and returns a view or a promise for a view for that model
720 * - create_view takes a model and returns a view or a promise for a view for that model
719 * - remove_view takes a view and destroys it (including calling `view.remove()`)
721 * - remove_view takes a view and destroys it (including calling `view.remove()`)
720 * - each time the update() function is called with a new list, the create and remove
722 * - each time the update() function is called with a new list, the create and remove
721 * callbacks will be called in an order so that if you append the views created in the
723 * callbacks will be called in an order so that if you append the views created in the
722 * create callback and remove the views in the remove callback, you will duplicate
724 * create callback and remove the views in the remove callback, you will duplicate
723 * the order of the list.
725 * the order of the list.
724 * - the remove callback defaults to just removing the view (e.g., pass in null for the second parameter)
726 * - the remove callback defaults to just removing the view (e.g., pass in null for the second parameter)
725 * - the context defaults to the created ViewList. If you pass another context, the create and remove
727 * - the context defaults to the created ViewList. If you pass another context, the create and remove
726 * will be called in that context.
728 * will be called in that context.
727 */
729 */
728
730
729 this.initialize.apply(this, arguments);
731 this.initialize.apply(this, arguments);
730 };
732 };
731
733
732 _.extend(ViewList.prototype, {
734 _.extend(ViewList.prototype, {
733 initialize: function(create_view, remove_view, context) {
735 initialize: function(create_view, remove_view, context) {
734 this._handler_context = context || this;
736 this._handler_context = context || this;
735 this._models = [];
737 this._models = [];
736 this.views = []; // list of promises for views
738 this.views = []; // list of promises for views
737 this._create_view = create_view;
739 this._create_view = create_view;
738 this._remove_view = remove_view || function(view) {view.remove();};
740 this._remove_view = remove_view || function(view) {view.remove();};
739 },
741 },
740
742
741 update: function(new_models, create_view, remove_view, context) {
743 update: function(new_models, create_view, remove_view, context) {
742 /**
744 /**
743 * the create_view, remove_view, and context arguments override the defaults
745 * the create_view, remove_view, and context arguments override the defaults
744 * specified when the list is created.
746 * specified when the list is created.
745 * after this function, the .views attribute is a list of promises for views
747 * after this function, the .views attribute is a list of promises for views
746 * if you want to perform some action on the list of views, do something like
748 * if you want to perform some action on the list of views, do something like
747 * `Promise.all(myviewlist.views).then(function(views) {...});`
749 * `Promise.all(myviewlist.views).then(function(views) {...});`
748 */
750 */
749 var remove = remove_view || this._remove_view;
751 var remove = remove_view || this._remove_view;
750 var create = create_view || this._create_view;
752 var create = create_view || this._create_view;
751 context = context || this._handler_context;
753 context = context || this._handler_context;
752 var i = 0;
754 var i = 0;
753 // first, skip past the beginning of the lists if they are identical
755 // first, skip past the beginning of the lists if they are identical
754 for (; i < new_models.length; i++) {
756 for (; i < new_models.length; i++) {
755 if (i >= this._models.length || new_models[i] !== this._models[i]) {
757 if (i >= this._models.length || new_models[i] !== this._models[i]) {
756 break;
758 break;
757 }
759 }
758 }
760 }
759
761
760 var first_removed = i;
762 var first_removed = i;
761 // Remove the non-matching items from the old list.
763 // Remove the non-matching items from the old list.
762 var removed = this.views.splice(first_removed, this.views.length-first_removed);
764 var removed = this.views.splice(first_removed, this.views.length-first_removed);
763 for (var j = 0; j < removed.length; j++) {
765 for (var j = 0; j < removed.length; j++) {
764 removed[j].then(function(view) {
766 removed[j].then(function(view) {
765 remove.call(context, view)
767 remove.call(context, view)
766 });
768 });
767 }
769 }
768
770
769 // Add the rest of the new list items.
771 // Add the rest of the new list items.
770 for (; i < new_models.length; i++) {
772 for (; i < new_models.length; i++) {
771 this.views.push(Promise.resolve(create.call(context, new_models[i])));
773 this.views.push(Promise.resolve(create.call(context, new_models[i])));
772 }
774 }
773 // make a copy of the input array
775 // make a copy of the input array
774 this._models = new_models.slice();
776 this._models = new_models.slice();
775 },
777 },
776
778
777 remove: function() {
779 remove: function() {
778 /**
780 /**
779 * removes every view in the list; convenience function for `.update([])`
781 * removes every view in the list; convenience function for `.update([])`
780 * that should be faster
782 * that should be faster
781 * returns a promise that resolves after this removal is done
783 * returns a promise that resolves after this removal is done
782 */
784 */
783 var that = this;
785 var that = this;
784 return Promise.all(this.views).then(function(views) {
786 return Promise.all(this.views).then(function(views) {
785 for (var i = 0; i < that.views.length; i++) {
787 for (var i = 0; i < that.views.length; i++) {
786 that._remove_view.call(that._handler_context, views[i]);
788 that._remove_view.call(that._handler_context, views[i]);
787 }
789 }
788 that.views = [];
790 that.views = [];
789 that._models = [];
791 that._models = [];
790 });
792 });
791 },
793 },
792 });
794 });
793
795
794 var widget = {
796 var widget = {
795 'unpack_models': unpack_models,
797 'unpack_models': unpack_models,
796 'WidgetModel': WidgetModel,
798 'WidgetModel': WidgetModel,
797 'WidgetView': WidgetView,
799 'WidgetView': WidgetView,
798 'DOMWidgetView': DOMWidgetView,
800 'DOMWidgetView': DOMWidgetView,
799 'ViewList': ViewList,
801 'ViewList': ViewList,
800 };
802 };
801
803
802 // For backwards compatability.
804 // For backwards compatability.
803 $.extend(IPython, widget);
805 $.extend(IPython, widget);
804
806
805 return widget;
807 return widget;
806 });
808 });
General Comments 0
You need to be logged in to leave comments. Login now