##// END OF EJS Templates
Make Python push initial state....
Jonathan Frederic -
Show More
@@ -1,259 +1,268
1 1 // Copyright (c) IPython Development Team.
2 2 // Distributed under the terms of the Modified BSD License.
3 3
4 4 define([
5 5 "underscore",
6 6 "backbone",
7 7 "jquery",
8 8 "base/js/namespace"
9 9 ], function (_, Backbone, $, IPython) {
10 10 "use strict";
11 11 //--------------------------------------------------------------------
12 12 // WidgetManager class
13 13 //--------------------------------------------------------------------
14 14 var WidgetManager = function (comm_manager, notebook) {
15 15 // Public constructor
16 16 WidgetManager._managers.push(this);
17 17
18 18 // Attach a comm manager to the
19 19 this.keyboard_manager = notebook.keyboard_manager;
20 20 this.notebook = notebook;
21 21 this.comm_manager = comm_manager;
22 22 this._models = {}; /* Dictionary of model ids and model instances */
23 23
24 24 // Register with the comm manager.
25 25 this.comm_manager.register_target('ipython.widget', $.proxy(this._handle_comm_open, this));
26 26 };
27 27
28 28 //--------------------------------------------------------------------
29 29 // Class level
30 30 //--------------------------------------------------------------------
31 31 WidgetManager._model_types = {}; /* Dictionary of model type names (target_name) and model types. */
32 32 WidgetManager._view_types = {}; /* Dictionary of view names and view types. */
33 33 WidgetManager._managers = []; /* List of widget managers */
34 34
35 35 WidgetManager.register_widget_model = function (model_name, model_type) {
36 36 // Registers a widget model by name.
37 37 WidgetManager._model_types[model_name] = model_type;
38 38 };
39 39
40 40 WidgetManager.register_widget_view = function (view_name, view_type) {
41 41 // Registers a widget view by name.
42 42 WidgetManager._view_types[view_name] = view_type;
43 43 };
44 44
45 45 //--------------------------------------------------------------------
46 46 // Instance level
47 47 //--------------------------------------------------------------------
48 48 WidgetManager.prototype.display_view = function(msg, model) {
49 49 // Displays a view for a particular model.
50 50 var cell = this.get_msg_cell(msg.parent_header.msg_id);
51 51 if (cell === null) {
52 52 console.log("Could not determine where the display" +
53 53 " message was from. Widget will not be displayed");
54 54 } else {
55 55 var that = this;
56 56 this.create_view(model, {cell: cell, callback: function(view) {
57 57 that._handle_display_view(view);
58 58 if (cell.widget_subarea) {
59 59 cell.widget_subarea.append(view.$el);
60 60 }
61 61 view.trigger('displayed');
62 62 }});
63 63 }
64 64 };
65 65
66 66 WidgetManager.prototype._handle_display_view = function (view) {
67 67 // Have the IPython keyboard manager disable its event
68 68 // handling so the widget can capture keyboard input.
69 69 // Note, this is only done on the outer most widgets.
70 70 if (this.keyboard_manager) {
71 71 this.keyboard_manager.register_events(view.$el);
72 72
73 73 if (view.additional_elements) {
74 74 for (var i = 0; i < view.additional_elements.length; i++) {
75 75 this.keyboard_manager.register_events(view.additional_elements[i]);
76 76 }
77 77 }
78 78 }
79 79 };
80 80
81 81
82 82 WidgetManager.prototype.create_view = function(model, options) {
83 83 // Creates a view for a particular model.
84 84
85 85 var view_name = model.get('_view_name');
86 86 var view_mod = model.get('_view_module');
87 87 var errback = options.errback || function(err) {console.log(err);};
88 88
89 89 var instantiate_view = function(ViewType) {
90 90 if (ViewType) {
91 91 // If a view is passed into the method, use that view's cell as
92 92 // the cell for the view that is created.
93 93 options = options || {};
94 94 if (options.parent !== undefined) {
95 95 options.cell = options.parent.options.cell;
96 96 }
97 97
98 98 // Create and render the view...
99 99 var parameters = {model: model, options: options};
100 100 var view = new ViewType(parameters);
101 101 view.render();
102 102 model.on('destroy', view.remove, view);
103 103 options.callback(view);
104 104 } else {
105 105 errback({unknown_view: true, view_name: view_name,
106 106 view_module: view_mod});
107 107 }
108 108 };
109 109
110 110 if (view_mod) {
111 111 require([view_mod], function(module) {
112 112 instantiate_view(module[view_name]);
113 113 }, errback);
114 114 } else {
115 115 instantiate_view(WidgetManager._view_types[view_name]);
116 116 }
117 117 };
118 118
119 119 WidgetManager.prototype.get_msg_cell = function (msg_id) {
120 120 var cell = null;
121 121 // First, check to see if the msg was triggered by cell execution.
122 122 if (this.notebook) {
123 123 cell = this.notebook.get_msg_cell(msg_id);
124 124 }
125 125 if (cell !== null) {
126 126 return cell;
127 127 }
128 128 // Second, check to see if a get_cell callback was defined
129 129 // for the message. get_cell callbacks are registered for
130 130 // widget messages, so this block is actually checking to see if the
131 131 // message was triggered by a widget.
132 132 var kernel = this.comm_manager.kernel;
133 133 if (kernel) {
134 134 var callbacks = kernel.get_callbacks_for_msg(msg_id);
135 135 if (callbacks && callbacks.iopub &&
136 136 callbacks.iopub.get_cell !== undefined) {
137 137 return callbacks.iopub.get_cell();
138 138 }
139 139 }
140 140
141 141 // Not triggered by a cell or widget (no get_cell callback
142 142 // exists).
143 143 return null;
144 144 };
145 145
146 146 WidgetManager.prototype.callbacks = function (view) {
147 147 // callback handlers specific a view
148 148 var callbacks = {};
149 149 if (view && view.options.cell) {
150 150
151 151 // Try to get output handlers
152 152 var cell = view.options.cell;
153 153 var handle_output = null;
154 154 var handle_clear_output = null;
155 155 if (cell.output_area) {
156 156 handle_output = $.proxy(cell.output_area.handle_output, cell.output_area);
157 157 handle_clear_output = $.proxy(cell.output_area.handle_clear_output, cell.output_area);
158 158 }
159 159
160 160 // Create callback dict using what is known
161 161 var that = this;
162 162 callbacks = {
163 163 iopub : {
164 164 output : handle_output,
165 165 clear_output : handle_clear_output,
166 166
167 167 // Special function only registered by widget messages.
168 168 // Allows us to get the cell for a message so we know
169 169 // where to add widgets if the code requires it.
170 170 get_cell : function () {
171 171 return cell;
172 172 },
173 173 },
174 174 };
175 175 }
176 176 return callbacks;
177 177 };
178 178
179 179 WidgetManager.prototype.get_model = function (model_id) {
180 180 // Look-up a model instance by its id.
181 181 var model = this._models[model_id];
182 182 if (model !== undefined && model.id == model_id) {
183 183 return model;
184 184 }
185 185 return null;
186 186 };
187 187
188 188 WidgetManager.prototype._handle_comm_open = function (comm, msg) {
189 189 // Handle when a comm is opened.
190 190 return this.create_model({model_name: msg.content.data.target_name, comm: comm});
191 191 };
192 192
193 WidgetManager.prototype.create_model = function (model_name, target_name) {
193 WidgetManager.prototype.create_model = function (model_name, target_name, init_state_callback) {
194 194 // Create and return a new widget model.
195 195 //
196 196 // Parameters
197 197 // ----------
198 198 // model_name: string
199 199 // Target name of the widget model to create.
200 200 // target_name: string
201 201 // Target name of the widget in the back-end.
202 return this._create_model({model_name: model_name, target_name: target_name});
202 // init_state_callback: (optional) callback
203 // Called when the first state push from the back-end is
204 // recieved.
205 return this._create_model({
206 model_name: model_name,
207 target_name: target_name,
208 init_state_callback: init_state_callback});
203 209 };
204 210
205 211 WidgetManager.prototype._create_model = function (options) {
206 212 // Create and return a new widget model.
207 213 //
208 214 // Parameters
209 215 // ----------
210 216 // options: dictionary
211 217 // Dictionary of options with the following contents:
212 218 // model_name: string
213 219 // Target name of the widget model to create.
214 220 // target_name: (optional) string
215 221 // Target name of the widget in the back-end.
216 222 // comm: (optional) Comm
223 // init_state_callback: (optional) callback
224 // Called when the first state push from the back-end is
225 // recieved.
217 226
218 227 // Create a comm if it wasn't provided.
219 228 var comm = options.comm;
220 229 if (!comm) {
221 230 comm = this.comm_manager.new_comm('ipython.widget', {'target_name': options.target_name});
222 231 }
223 232
224 233 // Create and return a new model that is connected to the comm.
225 234 var that = this;
226 235
227 236 var instantiate_model = function(ModelType) {
228 237 var model_id = comm.comm_id;
229 var widget_model = new ModelType(that, model_id, comm);
230 widget_model.on('comm:close', function () {sss
238 var widget_model = new ModelType(that, model_id, comm, options.init_state_callback);
239 widget_model.on('comm:close', function () {
231 240 delete that._models[model_id];
232 241 });
233 242 that._models[model_id] = widget_model;
234 243 };
235 244
236 245 var widget_type_name = msg.content.data.model_name;
237 246 var widget_module = msg.content.data.model_module;
238 247
239 248 if (widget_module) {
240 249 // Load the module containing the widget model
241 250 require([widget_module], function(mod) {
242 251 if (mod[widget_type_name]) {
243 252 instantiate_model(mod[widget_type_name]);
244 253 } else {
245 254 console.log("Error creating widget model: " + widget_type_name
246 255 + " not found in " + widget_module);
247 256 }
248 257 }, function(err) { console.log(err); });
249 258 } else {
250 259 // No module specified, load from the global models registry
251 260 instantiate_model(WidgetManager._model_types[widget_type_name]);
252 261 }
253 262 };
254 263
255 264 // Backwards compatability.
256 265 IPython.WidgetManager = WidgetManager;
257 266
258 267 return {'WidgetManager': WidgetManager};
259 268 });
@@ -1,613 +1,621
1 1 // Copyright (c) IPython Development Team.
2 2 // Distributed under the terms of the Modified BSD License.
3 3
4 4 define(["widgets/js/manager",
5 5 "underscore",
6 6 "backbone",
7 7 "jquery",
8 8 "base/js/namespace",
9 9 ], function(widgetmanager, _, Backbone, $, IPython){
10 10
11 11 var WidgetModel = Backbone.Model.extend({
12 constructor: function (widget_manager, model_id, comm) {
12 constructor: function (widget_manager, model_id, comm, init_state_callback) {
13 13 // Constructor
14 14 //
15 15 // Creates a WidgetModel instance.
16 16 //
17 17 // Parameters
18 18 // ----------
19 19 // widget_manager : WidgetManager instance
20 20 // model_id : string
21 21 // An ID unique to this model.
22 22 // comm : Comm instance (optional)
23 // init_state_callback : callback (optional)
24 // Called once when the first state message is recieved from
25 // the back-end.
23 26 this.widget_manager = widget_manager;
27 this.init_state_callback = init_state_callback;
24 28 this._buffered_state_diff = {};
25 29 this.pending_msgs = 0;
26 30 this.msg_buffer = null;
27 31 this.state_lock = null;
28 32 this.id = model_id;
29 33 this.views = {};
30 34
31 35 if (comm !== undefined) {
32 36 // Remember comm associated with the model.
33 37 this.comm = comm;
34 38 comm.model = this;
35 39
36 40 // Hook comm messages up to model.
37 41 comm.on_close($.proxy(this._handle_comm_closed, this));
38 42 comm.on_msg($.proxy(this._handle_comm_msg, this));
39 43 }
40 44 return Backbone.Model.apply(this);
41 45 },
42 46
43 47 send: function (content, callbacks) {
44 48 // Send a custom msg over the comm.
45 49 if (this.comm !== undefined) {
46 50 var data = {method: 'custom', content: content};
47 51 this.comm.send(data, callbacks);
48 52 this.pending_msgs++;
49 53 }
50 54 },
51 55
52 56 _handle_comm_closed: function (msg) {
53 57 // Handle when a widget is closed.
54 58 this.trigger('comm:close');
55 59 this.stopListening();
56 60 this.trigger('destroy', this);
57 61 delete this.comm.model; // Delete ref so GC will collect widget model.
58 62 delete this.comm;
59 63 delete this.model_id; // Delete id from model so widget manager cleans up.
60 64 for (var id in this.views) {
61 65 if (this.views.hasOwnProperty(id)) {
62 66 this.views[id].remove();
63 67 }
64 68 }
65 69 },
66 70
67 71 _handle_comm_msg: function (msg) {
68 72 // Handle incoming comm msg.
69 73 var method = msg.content.data.method;
70 74 switch (method) {
71 75 case 'update':
72 76 this.set_state(msg.content.data.state);
77 if (this.init_state_callback) {
78 this.init_state_callback.apply(this, [this]);
79 this.init_state_callback = null;
80 }
73 81 break;
74 82 case 'custom':
75 83 this.trigger('msg:custom', msg.content.data.content);
76 84 break;
77 85 case 'display':
78 86 this.widget_manager.display_view(msg, this);
79 87 break;
80 88 }
81 89 },
82 90
83 91 set_state: function (state) {
84 92 // Handle when a widget is updated via the python side.
85 93 this.state_lock = state;
86 94 try {
87 95 var that = this;
88 96 WidgetModel.__super__.set.apply(this, [Object.keys(state).reduce(function(obj, key) {
89 97 obj[key] = that._unpack_models(state[key]);
90 98 return obj;
91 99 }, {})]);
92 100 } finally {
93 101 this.state_lock = null;
94 102 }
95 103 },
96 104
97 105 _handle_status: function (msg, callbacks) {
98 106 // Handle status msgs.
99 107
100 108 // execution_state : ('busy', 'idle', 'starting')
101 109 if (this.comm !== undefined) {
102 110 if (msg.content.execution_state ==='idle') {
103 111 // Send buffer if this message caused another message to be
104 112 // throttled.
105 113 if (this.msg_buffer !== null &&
106 114 (this.get('msg_throttle') || 3) === this.pending_msgs) {
107 115 var data = {method: 'backbone', sync_method: 'update', sync_data: this.msg_buffer};
108 116 this.comm.send(data, callbacks);
109 117 this.msg_buffer = null;
110 118 } else {
111 119 --this.pending_msgs;
112 120 }
113 121 }
114 122 }
115 123 },
116 124
117 125 callbacks: function(view) {
118 126 // Create msg callbacks for a comm msg.
119 127 var callbacks = this.widget_manager.callbacks(view);
120 128
121 129 if (callbacks.iopub === undefined) {
122 130 callbacks.iopub = {};
123 131 }
124 132
125 133 var that = this;
126 134 callbacks.iopub.status = function (msg) {
127 135 that._handle_status(msg, callbacks);
128 136 };
129 137 return callbacks;
130 138 },
131 139
132 140 set: function(key, val, options) {
133 141 // Set a value.
134 142 var return_value = WidgetModel.__super__.set.apply(this, arguments);
135 143
136 144 // Backbone only remembers the diff of the most recent set()
137 145 // operation. Calling set multiple times in a row results in a
138 146 // loss of diff information. Here we keep our own running diff.
139 147 this._buffered_state_diff = $.extend(this._buffered_state_diff, this.changedAttributes() || {});
140 148 return return_value;
141 149 },
142 150
143 151 sync: function (method, model, options) {
144 152 // Handle sync to the back-end. Called when a model.save() is called.
145 153
146 154 // Make sure a comm exists.
147 155 var error = options.error || function() {
148 156 console.error('Backbone sync error:', arguments);
149 157 };
150 158 if (this.comm === undefined) {
151 159 error();
152 160 return false;
153 161 }
154 162
155 163 // Delete any key value pairs that the back-end already knows about.
156 164 var attrs = (method === 'patch') ? options.attrs : model.toJSON(options);
157 165 if (this.state_lock !== null) {
158 166 var keys = Object.keys(this.state_lock);
159 167 for (var i=0; i<keys.length; i++) {
160 168 var key = keys[i];
161 169 if (attrs[key] === this.state_lock[key]) {
162 170 delete attrs[key];
163 171 }
164 172 }
165 173 }
166 174
167 175 // Only sync if there are attributes to send to the back-end.
168 176 attrs = this._pack_models(attrs);
169 177 if (_.size(attrs) > 0) {
170 178
171 179 // If this message was sent via backbone itself, it will not
172 180 // have any callbacks. It's important that we create callbacks
173 181 // so we can listen for status messages, etc...
174 182 var callbacks = options.callbacks || this.callbacks();
175 183
176 184 // Check throttle.
177 185 if (this.pending_msgs >= (this.get('msg_throttle') || 3)) {
178 186 // The throttle has been exceeded, buffer the current msg so
179 187 // it can be sent once the kernel has finished processing
180 188 // some of the existing messages.
181 189
182 190 // Combine updates if it is a 'patch' sync, otherwise replace updates
183 191 switch (method) {
184 192 case 'patch':
185 193 this.msg_buffer = $.extend(this.msg_buffer || {}, attrs);
186 194 break;
187 195 case 'update':
188 196 case 'create':
189 197 this.msg_buffer = attrs;
190 198 break;
191 199 default:
192 200 error();
193 201 return false;
194 202 }
195 203 this.msg_buffer_callbacks = callbacks;
196 204
197 205 } else {
198 206 // We haven't exceeded the throttle, send the message like
199 207 // normal.
200 208 var data = {method: 'backbone', sync_data: attrs};
201 209 this.comm.send(data, callbacks);
202 210 this.pending_msgs++;
203 211 }
204 212 }
205 213 // Since the comm is a one-way communication, assume the message
206 214 // arrived. Don't call success since we don't have a model back from the server
207 215 // this means we miss out on the 'sync' event.
208 216 this._buffered_state_diff = {};
209 217 },
210 218
211 219 save_changes: function(callbacks) {
212 220 // Push this model's state to the back-end
213 221 //
214 222 // This invokes a Backbone.Sync.
215 223 this.save(this._buffered_state_diff, {patch: true, callbacks: callbacks});
216 224 },
217 225
218 226 _pack_models: function(value) {
219 227 // Replace models with model ids recursively.
220 228 var that = this;
221 229 var packed;
222 230 if (value instanceof Backbone.Model) {
223 231 return "IPY_MODEL_" + value.id;
224 232
225 233 } else if ($.isArray(value)) {
226 234 packed = [];
227 235 _.each(value, function(sub_value, key) {
228 236 packed.push(that._pack_models(sub_value));
229 237 });
230 238 return packed;
231 239
232 240 } else if (value instanceof Object) {
233 241 packed = {};
234 242 _.each(value, function(sub_value, key) {
235 243 packed[key] = that._pack_models(sub_value);
236 244 });
237 245 return packed;
238 246
239 247 } else {
240 248 return value;
241 249 }
242 250 },
243 251
244 252 _unpack_models: function(value) {
245 253 // Replace model ids with models recursively.
246 254 var that = this;
247 255 var unpacked;
248 256 if ($.isArray(value)) {
249 257 unpacked = [];
250 258 _.each(value, function(sub_value, key) {
251 259 unpacked.push(that._unpack_models(sub_value));
252 260 });
253 261 return unpacked;
254 262
255 263 } else if (value instanceof Object) {
256 264 unpacked = {};
257 265 _.each(value, function(sub_value, key) {
258 266 unpacked[key] = that._unpack_models(sub_value);
259 267 });
260 268 return unpacked;
261 269
262 270 } else if (typeof value === 'string' && value.slice(0,10) === "IPY_MODEL_") {
263 271 var model = this.widget_manager.get_model(value.slice(10, value.length));
264 272 if (model) {
265 273 return model;
266 274 } else {
267 275 return value;
268 276 }
269 277 } else {
270 278 return value;
271 279 }
272 280 },
273 281
274 282 on_some_change: function(keys, callback, context) {
275 283 // on_some_change(["key1", "key2"], foo, context) differs from
276 284 // on("change:key1 change:key2", foo, context).
277 285 // If the widget attributes key1 and key2 are both modified,
278 286 // the second form will result in foo being called twice
279 287 // while the first will call foo only once.
280 288 this.on('change', function() {
281 289 if (keys.some(this.hasChanged, this)) {
282 290 callback.apply(context);
283 291 }
284 292 }, this);
285 293
286 294 },
287 295 });
288 296 widgetmanager.WidgetManager.register_widget_model('WidgetModel', WidgetModel);
289 297
290 298
291 299 var WidgetView = Backbone.View.extend({
292 300 initialize: function(parameters) {
293 301 // Public constructor.
294 302 this.model.on('change',this.update,this);
295 303 this.options = parameters.options;
296 304 this.child_model_views = {};
297 305 this.child_views = {};
298 306 this.id = this.id || IPython.utils.uuid();
299 307 this.model.views[this.id] = this;
300 308 this.on('displayed', function() {
301 309 this.is_displayed = true;
302 310 }, this);
303 311 },
304 312
305 313 update: function(){
306 314 // Triggered on model change.
307 315 //
308 316 // Update view to be consistent with this.model
309 317 },
310 318
311 319 create_child_view: function(child_model, options) {
312 320 // Create and return a child view.
313 321 //
314 322 // -given a model and (optionally) a view name if the view name is
315 323 // not given, it defaults to the model's default view attribute.
316 324
317 325 // TODO: this is hacky, and makes the view depend on this cell attribute and widget manager behavior
318 326 // it would be great to have the widget manager add the cell metadata
319 327 // to the subview without having to add it here.
320 328 var that = this;
321 329 var old_callback = options.callback || function(view) {};
322 330 options = $.extend({ parent: this, callback: function(child_view) {
323 331 // Associate the view id with the model id.
324 332 if (that.child_model_views[child_model.id] === undefined) {
325 333 that.child_model_views[child_model.id] = [];
326 334 }
327 335 that.child_model_views[child_model.id].push(child_view.id);
328 336
329 337 // Remember the view by id.
330 338 that.child_views[child_view.id] = child_view;
331 339 old_callback(child_view);
332 340 }}, options || {});
333 341
334 342 this.model.widget_manager.create_view(child_model, options);
335 343 },
336 344
337 345 pop_child_view: function(child_model) {
338 346 // Delete a child view that was previously created using create_child_view.
339 347 var view_ids = this.child_model_views[child_model.id];
340 348 if (view_ids !== undefined) {
341 349
342 350 // Only delete the first view in the list.
343 351 var view_id = view_ids[0];
344 352 var view = this.child_views[view_id];
345 353 delete this.child_views[view_id];
346 354 view_ids.splice(0,1);
347 355 delete child_model.views[view_id];
348 356
349 357 // Remove the view list specific to this model if it is empty.
350 358 if (view_ids.length === 0) {
351 359 delete this.child_model_views[child_model.id];
352 360 }
353 361 return view;
354 362 }
355 363 return null;
356 364 },
357 365
358 366 do_diff: function(old_list, new_list, removed_callback, added_callback) {
359 367 // Difference a changed list and call remove and add callbacks for
360 368 // each removed and added item in the new list.
361 369 //
362 370 // Parameters
363 371 // ----------
364 372 // old_list : array
365 373 // new_list : array
366 374 // removed_callback : Callback(item)
367 375 // Callback that is called for each item removed.
368 376 // added_callback : Callback(item)
369 377 // Callback that is called for each item added.
370 378
371 379 // Walk the lists until an unequal entry is found.
372 380 var i;
373 381 for (i = 0; i < new_list.length; i++) {
374 382 if (i >= old_list.length || new_list[i] !== old_list[i]) {
375 383 break;
376 384 }
377 385 }
378 386
379 387 // Remove the non-matching items from the old list.
380 388 for (var j = i; j < old_list.length; j++) {
381 389 removed_callback(old_list[j]);
382 390 }
383 391
384 392 // Add the rest of the new list items.
385 393 for (; i < new_list.length; i++) {
386 394 added_callback(new_list[i]);
387 395 }
388 396 },
389 397
390 398 callbacks: function(){
391 399 // Create msg callbacks for a comm msg.
392 400 return this.model.callbacks(this);
393 401 },
394 402
395 403 render: function(){
396 404 // Render the view.
397 405 //
398 406 // By default, this is only called the first time the view is created
399 407 },
400 408
401 409 show: function(){
402 410 // Show the widget-area
403 411 if (this.options && this.options.cell &&
404 412 this.options.cell.widget_area !== undefined) {
405 413 this.options.cell.widget_area.show();
406 414 }
407 415 },
408 416
409 417 send: function (content) {
410 418 // Send a custom msg associated with this view.
411 419 this.model.send(content, this.callbacks());
412 420 },
413 421
414 422 touch: function () {
415 423 this.model.save_changes(this.callbacks());
416 424 },
417 425
418 426 after_displayed: function (callback, context) {
419 427 // Calls the callback right away is the view is already displayed
420 428 // otherwise, register the callback to the 'displayed' event.
421 429 if (this.is_displayed) {
422 430 callback.apply(context);
423 431 } else {
424 432 this.on('displayed', callback, context);
425 433 }
426 434 },
427 435 });
428 436
429 437
430 438 var DOMWidgetView = WidgetView.extend({
431 439 initialize: function (parameters) {
432 440 // Public constructor
433 441 DOMWidgetView.__super__.initialize.apply(this, [parameters]);
434 442 this.on('displayed', this.show, this);
435 443 this.model.on('change:visible', this.update_visible, this);
436 444 this.model.on('change:_css', this.update_css, this);
437 445
438 446 this.model.on('change:_dom_classes', function(model, new_classes) {
439 447 var old_classes = model.previous('_dom_classes');
440 448 this.update_classes(old_classes, new_classes);
441 449 }, this);
442 450
443 451 this.model.on('change:color', function (model, value) {
444 452 this.update_attr('color', value); }, this);
445 453
446 454 this.model.on('change:background_color', function (model, value) {
447 455 this.update_attr('background', value); }, this);
448 456
449 457 this.model.on('change:width', function (model, value) {
450 458 this.update_attr('width', value); }, this);
451 459
452 460 this.model.on('change:height', function (model, value) {
453 461 this.update_attr('height', value); }, this);
454 462
455 463 this.model.on('change:border_color', function (model, value) {
456 464 this.update_attr('border-color', value); }, this);
457 465
458 466 this.model.on('change:border_width', function (model, value) {
459 467 this.update_attr('border-width', value); }, this);
460 468
461 469 this.model.on('change:border_style', function (model, value) {
462 470 this.update_attr('border-style', value); }, this);
463 471
464 472 this.model.on('change:font_style', function (model, value) {
465 473 this.update_attr('font-style', value); }, this);
466 474
467 475 this.model.on('change:font_weight', function (model, value) {
468 476 this.update_attr('font-weight', value); }, this);
469 477
470 478 this.model.on('change:font_size', function (model, value) {
471 479 this.update_attr('font-size', this._default_px(value)); }, this);
472 480
473 481 this.model.on('change:font_family', function (model, value) {
474 482 this.update_attr('font-family', value); }, this);
475 483
476 484 this.model.on('change:padding', function (model, value) {
477 485 this.update_attr('padding', value); }, this);
478 486
479 487 this.model.on('change:margin', function (model, value) {
480 488 this.update_attr('margin', this._default_px(value)); }, this);
481 489
482 490 this.model.on('change:border_radius', function (model, value) {
483 491 this.update_attr('border-radius', this._default_px(value)); }, this);
484 492
485 493 this.after_displayed(function() {
486 494 this.update_visible(this.model, this.model.get("visible"));
487 495 this.update_classes([], this.model.get('_dom_classes'));
488 496
489 497 this.update_attr('color', this.model.get('color'));
490 498 this.update_attr('background', this.model.get('background_color'));
491 499 this.update_attr('width', this.model.get('width'));
492 500 this.update_attr('height', this.model.get('height'));
493 501 this.update_attr('border-color', this.model.get('border_color'));
494 502 this.update_attr('border-width', this.model.get('border_width'));
495 503 this.update_attr('border-style', this.model.get('border_style'));
496 504 this.update_attr('font-style', this.model.get('font_style'));
497 505 this.update_attr('font-weight', this.model.get('font_weight'));
498 506 this.update_attr('font-size', this.model.get('font_size'));
499 507 this.update_attr('font-family', this.model.get('font_family'));
500 508 this.update_attr('padding', this.model.get('padding'));
501 509 this.update_attr('margin', this.model.get('margin'));
502 510 this.update_attr('border-radius', this.model.get('border_radius'));
503 511
504 512 this.update_css(this.model, this.model.get("_css"));
505 513 }, this);
506 514 },
507 515
508 516 _default_px: function(value) {
509 517 // Makes browser interpret a numerical string as a pixel value.
510 518 if (/^\d+\.?(\d+)?$/.test(value.trim())) {
511 519 return value.trim() + 'px';
512 520 }
513 521 return value;
514 522 },
515 523
516 524 update_attr: function(name, value) {
517 525 // Set a css attr of the widget view.
518 526 this.$el.css(name, value);
519 527 },
520 528
521 529 update_visible: function(model, value) {
522 530 // Update visibility
523 531 this.$el.toggle(value);
524 532 },
525 533
526 534 update_css: function (model, css) {
527 535 // Update the css styling of this view.
528 536 var e = this.$el;
529 537 if (css === undefined) {return;}
530 538 for (var i = 0; i < css.length; i++) {
531 539 // Apply the css traits to all elements that match the selector.
532 540 var selector = css[i][0];
533 541 var elements = this._get_selector_element(selector);
534 542 if (elements.length > 0) {
535 543 var trait_key = css[i][1];
536 544 var trait_value = css[i][2];
537 545 elements.css(trait_key ,trait_value);
538 546 }
539 547 }
540 548 },
541 549
542 550 update_classes: function (old_classes, new_classes, $el) {
543 551 // Update the DOM classes applied to an element, default to this.$el.
544 552 if ($el===undefined) {
545 553 $el = this.$el;
546 554 }
547 555 this.do_diff(old_classes, new_classes, function(removed) {
548 556 $el.removeClass(removed);
549 557 }, function(added) {
550 558 $el.addClass(added);
551 559 });
552 560 },
553 561
554 562 update_mapped_classes: function(class_map, trait_name, previous_trait_value, $el) {
555 563 // Update the DOM classes applied to the widget based on a single
556 564 // trait's value.
557 565 //
558 566 // Given a trait value classes map, this function automatically
559 567 // handles applying the appropriate classes to the widget element
560 568 // and removing classes that are no longer valid.
561 569 //
562 570 // Parameters
563 571 // ----------
564 572 // class_map: dictionary
565 573 // Dictionary of trait values to class lists.
566 574 // Example:
567 575 // {
568 576 // success: ['alert', 'alert-success'],
569 577 // info: ['alert', 'alert-info'],
570 578 // warning: ['alert', 'alert-warning'],
571 579 // danger: ['alert', 'alert-danger']
572 580 // };
573 581 // trait_name: string
574 582 // Name of the trait to check the value of.
575 583 // previous_trait_value: optional string, default ''
576 584 // Last trait value
577 585 // $el: optional jQuery element handle, defaults to this.$el
578 586 // Element that the classes are applied to.
579 587 var key = previous_trait_value;
580 588 if (key === undefined) {
581 589 key = this.model.previous(trait_name);
582 590 }
583 591 var old_classes = class_map[key] ? class_map[key] : [];
584 592 key = this.model.get(trait_name);
585 593 var new_classes = class_map[key] ? class_map[key] : [];
586 594
587 595 this.update_classes(old_classes, new_classes, $el || this.$el);
588 596 },
589 597
590 598 _get_selector_element: function (selector) {
591 599 // Get the elements via the css selector.
592 600 var elements;
593 601 if (!selector) {
594 602 elements = this.$el;
595 603 } else {
596 604 elements = this.$el.find(selector).addBack(selector);
597 605 }
598 606 return elements;
599 607 },
600 608 });
601 609
602 610
603 611 var widget = {
604 612 'WidgetModel': WidgetModel,
605 613 'WidgetView': WidgetView,
606 614 'DOMWidgetView': DOMWidgetView,
607 615 };
608 616
609 617 // For backwards compatability.
610 618 $.extend(IPython, widget);
611 619
612 620 return widget;
613 621 });
@@ -1,469 +1,470
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 collections
17 17
18 18 from IPython.core.getipython import get_ipython
19 19 from IPython.kernel.comm import Comm
20 20 from IPython.config import LoggingConfigurable
21 21 from IPython.utils.importstring import import_item
22 22 from IPython.utils.traitlets import Unicode, Dict, Instance, Bool, List, \
23 23 CaselessStrEnum, Tuple, CUnicode, Int, Set
24 24 from IPython.utils.py3compat import string_types
25 25
26 26 #-----------------------------------------------------------------------------
27 27 # Classes
28 28 #-----------------------------------------------------------------------------
29 29 class CallbackDispatcher(LoggingConfigurable):
30 30 """A structure for registering and running callbacks"""
31 31 callbacks = List()
32 32
33 33 def __call__(self, *args, **kwargs):
34 34 """Call all of the registered callbacks."""
35 35 value = None
36 36 for callback in self.callbacks:
37 37 try:
38 38 local_value = callback(*args, **kwargs)
39 39 except Exception as e:
40 40 ip = get_ipython()
41 41 if ip is None:
42 42 self.log.warn("Exception in callback %s: %s", callback, e, exc_info=True)
43 43 else:
44 44 ip.showtraceback()
45 45 else:
46 46 value = local_value if local_value is not None else value
47 47 return value
48 48
49 49 def register_callback(self, callback, remove=False):
50 50 """(Un)Register a callback
51 51
52 52 Parameters
53 53 ----------
54 54 callback: method handle
55 55 Method to be registered or unregistered.
56 56 remove=False: bool
57 57 Whether to unregister the callback."""
58 58
59 59 # (Un)Register the callback.
60 60 if remove and callback in self.callbacks:
61 61 self.callbacks.remove(callback)
62 62 elif not remove and callback not in self.callbacks:
63 63 self.callbacks.append(callback)
64 64
65 65 def _show_traceback(method):
66 66 """decorator for showing tracebacks in IPython"""
67 67 def m(self, *args, **kwargs):
68 68 try:
69 69 return(method(self, *args, **kwargs))
70 70 except Exception as e:
71 71 ip = get_ipython()
72 72 if ip is None:
73 73 self.log.warn("Exception in widget method %s: %s", method, e, exc_info=True)
74 74 else:
75 75 ip.showtraceback()
76 76 return m
77 77
78 78 class Widget(LoggingConfigurable):
79 79 #-------------------------------------------------------------------------
80 80 # Class attributes
81 81 #-------------------------------------------------------------------------
82 82 _widget_construction_callback = None
83 83 widgets = {}
84 84
85 85 @staticmethod
86 86 def on_widget_constructed(callback):
87 87 """Registers a callback to be called when a widget is constructed.
88 88
89 89 The callback must have the following signature:
90 90 callback(widget)"""
91 91 Widget._widget_construction_callback = callback
92 92
93 93 @staticmethod
94 94 def _call_widget_constructed(widget):
95 95 """Static method, called when a widget is constructed."""
96 96 if Widget._widget_construction_callback is not None and callable(Widget._widget_construction_callback):
97 97 Widget._widget_construction_callback(widget)
98 98
99 99 @staticmethod
100 100 def handle_comm_opened(comm, msg):
101 101 """Static method, called when a widget is constructed."""
102 102 target_name = msg['content']['data']['target_name']
103 103 widget_class = import_item(target_name)
104 104 widget = widget_class(open_comm=False)
105 105 widget.set_comm(comm)
106 106
107 107
108 108 #-------------------------------------------------------------------------
109 109 # Traits
110 110 #-------------------------------------------------------------------------
111 111 _model_module = Unicode(None, allow_none=True, help="""A requirejs module name
112 112 in which to find _model_name. If empty, look in the global registry.""")
113 113 _model_name = Unicode('WidgetModel', help="""Name of the backbone model
114 114 registered in the front-end to create and sync this widget with.""")
115 115 _view_module = Unicode(help="""A requirejs module in which to find _view_name.
116 116 If empty, look in the global registry.""", sync=True)
117 117 _view_name = Unicode(None, allow_none=True, help="""Default view registered in the front-end
118 118 to use to represent the widget.""", sync=True)
119 119 comm = Instance('IPython.kernel.comm.Comm')
120 120
121 121 msg_throttle = Int(3, sync=True, help="""Maximum number of msgs the
122 122 front-end can send before receiving an idle msg from the back-end.""")
123 123
124 124 version = Int(0, sync=True, help="""Widget's version""")
125 125 keys = List()
126 126 def _keys_default(self):
127 127 return [name for name in self.traits(sync=True)]
128 128
129 129 _property_lock = Tuple((None, None))
130 130 _send_state_lock = Int(0)
131 131 _states_to_send = Set(allow_none=False)
132 132 _display_callbacks = Instance(CallbackDispatcher, ())
133 133 _msg_callbacks = Instance(CallbackDispatcher, ())
134 134
135 135 #-------------------------------------------------------------------------
136 136 # (Con/de)structor
137 137 #-------------------------------------------------------------------------
138 138 def __init__(self, open_comm=True, **kwargs):
139 139 """Public constructor"""
140 140 self._model_id = kwargs.pop('model_id', None)
141 141 super(Widget, self).__init__(**kwargs)
142 142
143 143 Widget._call_widget_constructed(self)
144 144 if open_comm:
145 145 self.open()
146 146
147 147 def __del__(self):
148 148 """Object disposal"""
149 149 self.close()
150 150
151 151 #-------------------------------------------------------------------------
152 152 # Properties
153 153 #-------------------------------------------------------------------------
154 154
155 155 def open(self):
156 156 """Open a comm to the frontend if one isn't already open."""
157 157 if self.comm is None:
158 158 args = dict(target_name='ipython.widget',
159 159 data={'model_name': self._model_name,
160 160 'model_module': self._model_module})
161 161 if self._model_id is not None:
162 162 args['comm_id'] = self._model_id
163 163 self.set_comm(Comm(**args))
164 164
165 # first update
166 self.send_state()
167
168 165 def set_comm(self, comm):
169 166 """Set's the comm of the widget."""
170 167 self.comm = comm
171 168 self._model_id = self.model_id
172 169
173 170 self.comm.on_msg(self._handle_msg)
174 171 Widget.widgets[self.model_id] = self
175 172
173 # first update
174 self.send_state()
175
176
176 177 @property
177 178 def model_id(self):
178 179 """Gets the model id of this widget.
179 180
180 181 If a Comm doesn't exist yet, a Comm will be created automagically."""
181 182 return self.comm.comm_id
182 183
183 184 #-------------------------------------------------------------------------
184 185 # Methods
185 186 #-------------------------------------------------------------------------
186 187
187 188 def close(self):
188 189 """Close method.
189 190
190 191 Closes the underlying comm.
191 192 When the comm is closed, all of the widget views are automatically
192 193 removed from the front-end."""
193 194 if self.comm is not None:
194 195 Widget.widgets.pop(self.model_id, None)
195 196 self.comm.close()
196 197 self.comm = None
197 198
198 199 def send_state(self, key=None):
199 200 """Sends the widget state, or a piece of it, to the front-end.
200 201
201 202 Parameters
202 203 ----------
203 204 key : unicode, or iterable (optional)
204 205 A single property's name or iterable of property names to sync with the front-end.
205 206 """
206 207 self._send({
207 208 "method" : "update",
208 209 "state" : self.get_state(key=key)
209 210 })
210 211
211 212 def get_state(self, key=None):
212 213 """Gets the widget state, or a piece of it.
213 214
214 215 Parameters
215 216 ----------
216 217 key : unicode or iterable (optional)
217 218 A single property's name or iterable of property names to get.
218 219 """
219 220 if key is None:
220 221 keys = self.keys
221 222 elif isinstance(key, string_types):
222 223 keys = [key]
223 224 elif isinstance(key, collections.Iterable):
224 225 keys = key
225 226 else:
226 227 raise ValueError("key must be a string, an iterable of keys, or None")
227 228 state = {}
228 229 for k in keys:
229 230 f = self.trait_metadata(k, 'to_json', self._trait_to_json)
230 231 value = getattr(self, k)
231 232 state[k] = f(value)
232 233 return state
233 234
234 235 def set_state(self, sync_data):
235 236 """Called when a state is received from the front-end."""
236 237 for name in self.keys:
237 238 if name in sync_data:
238 239 json_value = sync_data[name]
239 240 from_json = self.trait_metadata(name, 'from_json', self._trait_from_json)
240 241 with self._lock_property(name, json_value):
241 242 setattr(self, name, from_json(json_value))
242 243
243 244 def send(self, content):
244 245 """Sends a custom msg to the widget model in the front-end.
245 246
246 247 Parameters
247 248 ----------
248 249 content : dict
249 250 Content of the message to send.
250 251 """
251 252 self._send({"method": "custom", "content": content})
252 253
253 254 def on_msg(self, callback, remove=False):
254 255 """(Un)Register a custom msg receive callback.
255 256
256 257 Parameters
257 258 ----------
258 259 callback: callable
259 260 callback will be passed two arguments when a message arrives::
260 261
261 262 callback(widget, content)
262 263
263 264 remove: bool
264 265 True if the callback should be unregistered."""
265 266 self._msg_callbacks.register_callback(callback, remove=remove)
266 267
267 268 def on_displayed(self, callback, remove=False):
268 269 """(Un)Register a widget displayed callback.
269 270
270 271 Parameters
271 272 ----------
272 273 callback: method handler
273 274 Must have a signature of::
274 275
275 276 callback(widget, **kwargs)
276 277
277 278 kwargs from display are passed through without modification.
278 279 remove: bool
279 280 True if the callback should be unregistered."""
280 281 self._display_callbacks.register_callback(callback, remove=remove)
281 282
282 283 #-------------------------------------------------------------------------
283 284 # Support methods
284 285 #-------------------------------------------------------------------------
285 286 @contextmanager
286 287 def _lock_property(self, key, value):
287 288 """Lock a property-value pair.
288 289
289 290 The value should be the JSON state of the property.
290 291
291 292 NOTE: This, in addition to the single lock for all state changes, is
292 293 flawed. In the future we may want to look into buffering state changes
293 294 back to the front-end."""
294 295 self._property_lock = (key, value)
295 296 try:
296 297 yield
297 298 finally:
298 299 self._property_lock = (None, None)
299 300
300 301 @contextmanager
301 302 def hold_sync(self):
302 303 """Hold syncing any state until the context manager is released"""
303 304 # We increment a value so that this can be nested. Syncing will happen when
304 305 # all levels have been released.
305 306 self._send_state_lock += 1
306 307 try:
307 308 yield
308 309 finally:
309 310 self._send_state_lock -=1
310 311 if self._send_state_lock == 0:
311 312 self.send_state(self._states_to_send)
312 313 self._states_to_send.clear()
313 314
314 315 def _should_send_property(self, key, value):
315 316 """Check the property lock (property_lock)"""
316 317 to_json = self.trait_metadata(key, 'to_json', self._trait_to_json)
317 318 if (key == self._property_lock[0]
318 319 and to_json(value) == self._property_lock[1]):
319 320 return False
320 321 elif self._send_state_lock > 0:
321 322 self._states_to_send.add(key)
322 323 return False
323 324 else:
324 325 return True
325 326
326 327 # Event handlers
327 328 @_show_traceback
328 329 def _handle_msg(self, msg):
329 330 """Called when a msg is received from the front-end"""
330 331 data = msg['content']['data']
331 332 method = data['method']
332 333 if not method in ['backbone', 'custom']:
333 334 self.log.error('Unknown front-end to back-end widget msg with method "%s"' % method)
334 335
335 336 # Handle backbone sync methods CREATE, PATCH, and UPDATE all in one.
336 337 if method == 'backbone' and 'sync_data' in data:
337 338 sync_data = data['sync_data']
338 339 self.set_state(sync_data) # handles all methods
339 340
340 341 # Handle a custom msg from the front-end
341 342 elif method == 'custom':
342 343 if 'content' in data:
343 344 self._handle_custom_msg(data['content'])
344 345
345 346 def _handle_custom_msg(self, content):
346 347 """Called when a custom msg is received."""
347 348 self._msg_callbacks(self, content)
348 349
349 350 def _notify_trait(self, name, old_value, new_value):
350 351 """Called when a property has been changed."""
351 352 # Trigger default traitlet callback machinery. This allows any user
352 353 # registered validation to be processed prior to allowing the widget
353 354 # machinery to handle the state.
354 355 LoggingConfigurable._notify_trait(self, name, old_value, new_value)
355 356
356 357 # Send the state after the user registered callbacks for trait changes
357 358 # have all fired (allows for user to validate values).
358 359 if self.comm is not None and name in self.keys:
359 360 # Make sure this isn't information that the front-end just sent us.
360 361 if self._should_send_property(name, new_value):
361 362 # Send new state to front-end
362 363 self.send_state(key=name)
363 364
364 365 def _handle_displayed(self, **kwargs):
365 366 """Called when a view has been displayed for this widget instance"""
366 367 self._display_callbacks(self, **kwargs)
367 368
368 369 def _trait_to_json(self, x):
369 370 """Convert a trait value to json
370 371
371 372 Traverse lists/tuples and dicts and serialize their values as well.
372 373 Replace any widgets with their model_id
373 374 """
374 375 if isinstance(x, dict):
375 376 return {k: self._trait_to_json(v) for k, v in x.items()}
376 377 elif isinstance(x, (list, tuple)):
377 378 return [self._trait_to_json(v) for v in x]
378 379 elif isinstance(x, Widget):
379 380 return "IPY_MODEL_" + x.model_id
380 381 else:
381 382 return x # Value must be JSON-able
382 383
383 384 def _trait_from_json(self, x):
384 385 """Convert json values to objects
385 386
386 387 Replace any strings representing valid model id values to Widget references.
387 388 """
388 389 if isinstance(x, dict):
389 390 return {k: self._trait_from_json(v) for k, v in x.items()}
390 391 elif isinstance(x, (list, tuple)):
391 392 return [self._trait_from_json(v) for v in x]
392 393 elif isinstance(x, string_types) and x.startswith('IPY_MODEL_') and x[10:] in Widget.widgets:
393 394 # we want to support having child widgets at any level in a hierarchy
394 395 # trusting that a widget UUID will not appear out in the wild
395 396 return Widget.widgets[x[10:]]
396 397 else:
397 398 return x
398 399
399 400 def _ipython_display_(self, **kwargs):
400 401 """Called when `IPython.display.display` is called on the widget."""
401 402 # Show view.
402 403 if self._view_name is not None:
403 404 self._send({"method": "display"})
404 405 self._handle_displayed(**kwargs)
405 406
406 407 def _send(self, msg):
407 408 """Sends a message to the model in the front-end."""
408 409 self.comm.send(msg)
409 410
410 411
411 412 class DOMWidget(Widget):
412 413 visible = Bool(True, help="Whether the widget is visible.", sync=True)
413 414 _css = Tuple(sync=True, help="CSS property list: (selector, key, value)")
414 415 _dom_classes = Tuple(sync=True, help="DOM classes applied to widget.$el.")
415 416
416 417 width = CUnicode(sync=True)
417 418 height = CUnicode(sync=True)
418 419 padding = CUnicode(sync=True)
419 420 margin = CUnicode(sync=True)
420 421
421 422 color = Unicode(sync=True)
422 423 background_color = Unicode(sync=True)
423 424 border_color = Unicode(sync=True)
424 425
425 426 border_width = CUnicode(sync=True)
426 427 border_radius = CUnicode(sync=True)
427 428 border_style = CaselessStrEnum(values=[ # http://www.w3schools.com/cssref/pr_border-style.asp
428 429 'none',
429 430 'hidden',
430 431 'dotted',
431 432 'dashed',
432 433 'solid',
433 434 'double',
434 435 'groove',
435 436 'ridge',
436 437 'inset',
437 438 'outset',
438 439 'initial',
439 440 'inherit', ''],
440 441 default_value='', sync=True)
441 442
442 443 font_style = CaselessStrEnum(values=[ # http://www.w3schools.com/cssref/pr_font_font-style.asp
443 444 'normal',
444 445 'italic',
445 446 'oblique',
446 447 'initial',
447 448 'inherit', ''],
448 449 default_value='', sync=True)
449 450 font_weight = CaselessStrEnum(values=[ # http://www.w3schools.com/cssref/pr_font_weight.asp
450 451 'normal',
451 452 'bold',
452 453 'bolder',
453 454 'lighter',
454 455 'initial',
455 456 'inherit', ''] + [str(100 * (i+1)) for i in range(9)],
456 457 default_value='', sync=True)
457 458 font_size = CUnicode(sync=True)
458 459 font_family = Unicode(sync=True)
459 460
460 461 def __init__(self, *pargs, **kwargs):
461 462 super(DOMWidget, self).__init__(*pargs, **kwargs)
462 463
463 464 def _validate_border(name, old, new):
464 465 if new is not None and new != '':
465 466 if name != 'border_width' and not self.border_width:
466 467 self.border_width = 1
467 468 if name != 'border_style' and self.border_style == '':
468 469 self.border_style = 'solid'
469 470 self.on_trait_change(_validate_border, ['border_width', 'border_style', 'border_color'])
General Comments 0
You need to be logged in to leave comments. Login now