##// END OF EJS Templates
Merge pull request #6789 from jdfreder/persistence2...
Min RK -
r19376:71459274 merge
parent child Browse files
Show More
@@ -0,0 +1,16 b''
1 * Added a widget persistence API. This allows you to persist your notebooks interactive widgets.
2 Two levels of control are provided:
3 1. Higher level- ``WidgetManager.set_state_callbacks`` allows you to register callbacks for loading and saving widget state. The callbacks you register are automatically called when necessary.
4 2. Lower level- the ``WidgetManager`` Javascript class now has ``get_state`` and ``set_state`` methods that allow you to get and set the state of the widget runtime.
5
6 Example code for persisting your widget state to session data:
7
8 ::
9 %%javascript
10 require(['widgets/js/manager'], function(manager) {
11 manager.WidgetManager.set_state_callbacks(function() { // Load
12 return JSON.parse(sessionStorage.widgets_state || '{}');
13 }, function(state) { // Save
14 sessionStorage.widgets_state = JSON.stringify(state);
15 });
16 }); No newline at end of file
@@ -101,7 +101,7 b' define(['
101
101
102 this.last_msg_id = null;
102 this.last_msg_id = null;
103 this.completer = null;
103 this.completer = null;
104
104 this.widget_views = [];
105
105
106 var config = utils.mergeopt(CodeCell, this.config);
106 var config = utils.mergeopt(CodeCell, this.config);
107 Cell.apply(this,[{
107 Cell.apply(this,[{
@@ -191,12 +191,19 b' define(['
191 .addClass('widget-subarea')
191 .addClass('widget-subarea')
192 .appendTo(widget_area);
192 .appendTo(widget_area);
193 this.widget_subarea = widget_subarea;
193 this.widget_subarea = widget_subarea;
194 var that = this;
194 var widget_clear_buton = $('<button />')
195 var widget_clear_buton = $('<button />')
195 .addClass('close')
196 .addClass('close')
196 .html('&times;')
197 .html('&times;')
197 .click(function() {
198 .click(function() {
198 widget_area.slideUp('', function(){ widget_subarea.html(''); });
199 widget_area.slideUp('', function(){
199 })
200 for (var i = 0; i < that.widget_views.length; i++) {
201 that.widget_views[i].remove();
202 }
203 that.widget_views = [];
204 widget_subarea.html('');
205 });
206 })
200 .appendTo(widget_prompt);
207 .appendTo(widget_prompt);
201
208
202 var output = $('<div></div>');
209 var output = $('<div></div>');
@@ -210,6 +217,25 b' define(['
210 this.completer = new completer.Completer(this, this.events);
217 this.completer = new completer.Completer(this, this.events);
211 };
218 };
212
219
220 /**
221 * Display a widget view in the cell.
222 */
223 CodeCell.prototype.display_widget_view = function(view_promise) {
224
225 // Display a dummy element
226 var dummy = $('<div/>');
227 this.widget_subarea.append(dummy);
228
229 // Display the view.
230 var that = this;
231 return view_promise.then(function(view) {
232 that.widget_area.show();
233 dummy.replaceWith(view.$el);
234 that.widget_views.push(view);
235 return view;
236 });
237 };
238
213 /** @method bind_events */
239 /** @method bind_events */
214 CodeCell.prototype.bind_events = function () {
240 CodeCell.prototype.bind_events = function () {
215 Cell.prototype.bind_events.apply(this);
241 Cell.prototype.bind_events.apply(this);
@@ -322,6 +348,10 b' define(['
322 this.active_output_area.clear_output();
348 this.active_output_area.clear_output();
323
349
324 // Clear widget area
350 // Clear widget area
351 for (var i = 0; i < this.widget_views.length; i++) {
352 this.widget_views[i].remove();
353 }
354 this.widget_views = [];
325 this.widget_subarea.html('');
355 this.widget_subarea.html('');
326 this.widget_subarea.height('');
356 this.widget_subarea.height('');
327 this.widget_area.height('');
357 this.widget_area.height('');
@@ -1980,6 +1980,10 b' define(['
1980 return;
1980 return;
1981 }
1981 }
1982
1982
1983 // Trigger an event before save, which allows listeners to modify
1984 // the notebook as needed.
1985 this.events.trigger('before_save.Notebook');
1986
1983 // Create a JSON model to be sent to the server.
1987 // Create a JSON model to be sent to the server.
1984 var model = {
1988 var model = {
1985 type : "notebook",
1989 type : "notebook",
@@ -7,7 +7,8 b' define(['
7 "jquery",
7 "jquery",
8 "base/js/utils",
8 "base/js/utils",
9 "base/js/namespace",
9 "base/js/namespace",
10 ], function (_, Backbone, $, utils, IPython) {
10 "services/kernels/comm"
11 ], function (_, Backbone, $, utils, IPython, comm) {
11 "use strict";
12 "use strict";
12 //--------------------------------------------------------------------
13 //--------------------------------------------------------------------
13 // WidgetManager class
14 // WidgetManager class
@@ -22,10 +23,31 b' define(['
22 this.keyboard_manager = notebook.keyboard_manager;
23 this.keyboard_manager = notebook.keyboard_manager;
23 this.notebook = notebook;
24 this.notebook = notebook;
24 this.comm_manager = comm_manager;
25 this.comm_manager = comm_manager;
25 this._models = {}; /* Dictionary of model ids and model instances */
26 this.comm_target_name = 'ipython.widget';
27 this._models = {}; /* Dictionary of model ids and model instance promises */
26
28
27 // Register with the comm manager.
29 // Register with the comm manager.
28 this.comm_manager.register_target('ipython.widget', $.proxy(this._handle_comm_open, this));
30 this.comm_manager.register_target(this.comm_target_name, $.proxy(this._handle_comm_open, this));
31
32 // Load the initial state of the widget manager if a load callback was
33 // registered.
34 var that = this;
35 if (WidgetManager._load_callback) {
36 Promise.resolve(WidgetManager._load_callback.call(this)).then(function(state) {
37 that.set_state(state);
38 }).catch(utils.reject('Error loading widget manager state', true));
39 }
40
41 // Setup state saving code.
42 this.notebook.events.on('before_save.Notebook', function() {
43 var save_callback = WidgetManager._save_callback;
44 var options = WidgetManager._get_state_options;
45 if (save_callback) {
46 that.get_state(options).then(function(state) {
47 save_callback.call(that, state);
48 }).catch(utils.reject('Could not call widget save state callback.', true));
49 }
50 });
29 };
51 };
30
52
31 //--------------------------------------------------------------------
53 //--------------------------------------------------------------------
@@ -34,17 +56,52 b' define(['
34 WidgetManager._model_types = {}; /* Dictionary of model type names (target_name) and model types. */
56 WidgetManager._model_types = {}; /* Dictionary of model type names (target_name) and model types. */
35 WidgetManager._view_types = {}; /* Dictionary of view names and view types. */
57 WidgetManager._view_types = {}; /* Dictionary of view names and view types. */
36 WidgetManager._managers = []; /* List of widget managers */
58 WidgetManager._managers = []; /* List of widget managers */
59 WidgetManager._load_callback = null;
60 WidgetManager._save_callback = null;
37
61
38 WidgetManager.register_widget_model = function (model_name, model_type) {
62 WidgetManager.register_widget_model = function (model_name, model_type) {
39 // Registers a widget model by name.
63 /**
64 * Registers a widget model by name.
65 */
40 WidgetManager._model_types[model_name] = model_type;
66 WidgetManager._model_types[model_name] = model_type;
41 };
67 };
42
68
43 WidgetManager.register_widget_view = function (view_name, view_type) {
69 WidgetManager.register_widget_view = function (view_name, view_type) {
44 // Registers a widget view by name.
70 /**
71 * Registers a widget view by name.
72 */
45 WidgetManager._view_types[view_name] = view_type;
73 WidgetManager._view_types[view_name] = view_type;
46 };
74 };
47
75
76 WidgetManager.set_state_callbacks = function (load_callback, save_callback, options) {
77 /**
78 * Registers callbacks for widget state persistence.
79 *
80 * Parameters
81 * ----------
82 * load_callback: function()
83 * function that is called when the widget manager state should be
84 * loaded. This function should return a promise for the widget
85 * manager state. An empty state is an empty dictionary `{}`.
86 * save_callback: function(state as dictionary)
87 * function that is called when the notebook is saved or autosaved.
88 * The current state of the widget manager is passed in as the first
89 * argument.
90 */
91 WidgetManager._load_callback = load_callback;
92 WidgetManager._save_callback = save_callback;
93 WidgetManager._get_state_options = options;
94
95 // Use the load callback to immediately load widget states.
96 WidgetManager._managers.forEach(function(manager) {
97 if (load_callback) {
98 Promise.resolve(load_callback.call(manager)).then(function(state) {
99 manager.set_state(state);
100 }).catch(utils.reject('Error loading widget manager state', true));
101 }
102 });
103 };
104
48 //--------------------------------------------------------------------
105 //--------------------------------------------------------------------
49 // Instance level
106 // Instance level
50 //--------------------------------------------------------------------
107 //--------------------------------------------------------------------
@@ -52,21 +109,31 b' define(['
52 /**
109 /**
53 * Displays a view for a particular model.
110 * Displays a view for a particular model.
54 */
111 */
55 var that = this;
56 var cell = this.get_msg_cell(msg.parent_header.msg_id);
112 var cell = this.get_msg_cell(msg.parent_header.msg_id);
57 if (cell === null) {
113 if (cell === null) {
58 return Promise.reject(new Error("Could not determine where the display" +
114 return Promise.reject(new Error("Could not determine where the display" +
59 " message was from. Widget will not be displayed"));
115 " message was from. Widget will not be displayed"));
60 } else if (cell.widget_subarea) {
116 } else {
61 var dummy = $('<div />');
117 return this.display_view_in_cell(cell, model)
62 cell.widget_subarea.append(dummy);
118 .catch(utils.reject('Could not display view', true));
63 return this.create_view(model, {cell: cell}).then(
119 }
64 function(view) {
120 };
65 that._handle_display_view(view);
121
66 dummy.replaceWith(view.$el);
122 WidgetManager.prototype.display_view_in_cell = function(cell, model) {
67 view.trigger('displayed');
123 // Displays a view in a cell.
68 return view;
124 if (cell.display_widget_view) {
69 }).catch(utils.reject('Could not display view', true));
125 var that = this;
126 return cell.display_widget_view(this.create_view(model, {
127 cell: cell,
128 // Only set cell_index when view is displayed as directly.
129 cell_index: that.notebook.find_cell_index(cell),
130 })).then(function(view) {
131 that._handle_display_view(view);
132 view.trigger('displayed');
133 resolve(view);
134 }).catch(utils.reject('Could not create or display view', true));
135 } else {
136 return Promise.reject(new Error('Cell does not have a `display_widget_view` method'));
70 }
137 }
71 };
138 };
72
139
@@ -112,7 +179,13 b' define(['
112 return Promise.resolve(view.render()).then(function() {return view;});
179 return Promise.resolve(view.render()).then(function() {return view;});
113 }).catch(utils.reject("Couldn't create a view for model id '" + String(model.id) + "'", true));
180 }).catch(utils.reject("Couldn't create a view for model id '" + String(model.id) + "'", true));
114 });
181 });
115 model.views[utils.uuid()] = model.state_change;
182 var id = utils.uuid();
183 model.views[id] = model.state_change;
184 model.state_change.then(function(view) {
185 view.once('remove', function() {
186 delete view.model.views[id];
187 }, this);
188 });
116 return model.state_change;
189 return model.state_change;
117 };
190 };
118
191
@@ -238,6 +311,8 b' define(['
238 widget_model.once('comm:close', function () {
311 widget_model.once('comm:close', function () {
239 delete that._models[model_id];
312 delete that._models[model_id];
240 });
313 });
314 widget_model.name = options.model_name;
315 widget_model.module = options.model_module;
241 return widget_model;
316 return widget_model;
242
317
243 }, function(error) {
318 }, function(error) {
@@ -249,6 +324,139 b' define(['
249 return model_promise;
324 return model_promise;
250 };
325 };
251
326
327 WidgetManager.prototype.get_state = function(options) {
328 /**
329 * Asynchronously get the state of the widget manager.
330 *
331 * This includes all of the widget models and the cells that they are
332 * displayed in.
333 *
334 * Parameters
335 * ----------
336 * options: dictionary
337 * Dictionary of options with the following contents:
338 * only_displayed: (optional) boolean=false
339 * Only return models with one or more displayed views.
340 * not_live: (optional) boolean=false
341 * Include models that have comms with severed connections.
342 *
343 * Returns
344 * -------
345 * Promise for a state dictionary
346 */
347 var that = this;
348 return utils.resolve_promises_dict(this._models).then(function(models) {
349 var state = {};
350 for (var model_id in models) {
351 if (models.hasOwnProperty(model_id)) {
352 var model = models[model_id];
353
354 // If the model has one or more views defined for it,
355 // consider it displayed.
356 var displayed_flag = !(options && options.only_displayed) || Object.keys(model.views).length > 0;
357 var live_flag = (options && options.not_live) || model.comm_live;
358 if (displayed_flag && live_flag) {
359 state[model_id] = {
360 model_name: model.name,
361 model_module: model.module,
362 state: model.get_state(),
363 views: [],
364 };
365
366 // Get the views that are displayed *now*.
367 for (var id in model.views) {
368 if (model.views.hasOwnProperty(id)) {
369 var view = model.views[id];
370 if (view.options.cell_index) {
371 state[model_id].views.push(view.options.cell_index);
372 }
373 }
374 }
375 }
376 }
377 }
378 return state;
379 }).catch(utils.reject('Could not get state of widget manager', true));
380 };
381
382 WidgetManager.prototype.set_state = function(state) {
383 /**
384 * Set the notebook's state.
385 *
386 * Reconstructs all of the widget models and attempts to redisplay the
387 * widgets in the appropriate cells by cell index.
388 */
389
390 // Get the kernel when it's available.
391 var that = this;
392 return this._get_connected_kernel().then(function(kernel) {
393
394 // Recreate all the widget models for the given state and
395 // display the views.
396 that.all_views = [];
397 var model_ids = Object.keys(state);
398 for (var i = 0; i < model_ids.length; i++) {
399 var model_id = model_ids[i];
400
401 // Recreate a comm using the widget's model id (model_id == comm_id).
402 var new_comm = new comm.Comm(kernel.widget_manager.comm_target_name, model_id);
403 kernel.comm_manager.register_comm(new_comm);
404
405 // Create the model using the recreated comm. When the model is
406 // created we don't know yet if the comm is valid so set_comm_live
407 // false. Once we receive the first state push from the back-end
408 // we know the comm is alive.
409 var views = kernel.widget_manager.create_model({
410 comm: new_comm,
411 model_name: state[model_id].model_name,
412 model_module: state[model_id].model_module})
413 .then(function(model) {
414
415 model.set_comm_live(false);
416 var view_promise = Promise.resolve().then(function() {
417 return model.set_state(state[model.id].state);
418 }).then(function() {
419 model.request_state().then(function() {
420 model.set_comm_live(true);
421 });
422
423 // Display the views of the model.
424 var views = [];
425 var model_views = state[model.id].views;
426 for (var j=0; j<model_views.length; j++) {
427 var cell_index = model_views[j];
428 var cell = that.notebook.get_cell(cell_index);
429 views.push(that.display_view_in_cell(cell, model));
430 }
431 return Promise.all(views);
432 });
433 return view_promise;
434 });
435 that.all_views.push(views);
436 }
437 return Promise.all(that.all_views);
438 }).catch(utils.reject('Could not set widget manager state.', true));
439 };
440
441 WidgetManager.prototype._get_connected_kernel = function() {
442 /**
443 * Gets a promise for a connected kernel
444 */
445 var that = this;
446 return new Promise(function(resolve, reject) {
447 if (that.comm_manager &&
448 that.comm_manager.kernel &&
449 that.comm_manager.kernel.is_connected()) {
450
451 resolve(that.comm_manager.kernel);
452 } else {
453 that.notebook.events.on('kernel_connected.Kernel', function(event, data) {
454 resolve(data.kernel);
455 });
456 }
457 });
458 };
459
252 // Backwards compatibility.
460 // Backwards compatibility.
253 IPython.WidgetManager = WidgetManager;
461 IPython.WidgetManager = WidgetManager;
254
462
@@ -31,6 +31,7 b' define(["widgets/js/manager",'
31 this.state_lock = null;
31 this.state_lock = null;
32 this.id = model_id;
32 this.id = model_id;
33 this.views = {};
33 this.views = {};
34 this._resolve_received_state = {};
34
35
35 if (comm !== undefined) {
36 if (comm !== undefined) {
36 // Remember comm associated with the model.
37 // Remember comm associated with the model.
@@ -40,6 +41,11 b' define(["widgets/js/manager",'
40 // Hook comm messages up to model.
41 // Hook comm messages up to model.
41 comm.on_close($.proxy(this._handle_comm_closed, this));
42 comm.on_close($.proxy(this._handle_comm_closed, this));
42 comm.on_msg($.proxy(this._handle_comm_msg, this));
43 comm.on_msg($.proxy(this._handle_comm_msg, this));
44
45 // Assume the comm is alive.
46 this.set_comm_live(true);
47 } else {
48 this.set_comm_live(false);
43 }
49 }
44 return Backbone.Model.apply(this);
50 return Backbone.Model.apply(this);
45 },
51 },
@@ -55,11 +61,43 b' define(["widgets/js/manager",'
55 }
61 }
56 },
62 },
57
63
58 _handle_comm_closed: function (msg) {
64 request_state: function(callbacks) {
65 /**
66 * Request a state push from the back-end.
67 */
68 if (!this.comm) {
69 console.error("Could not request_state because comm doesn't exist!");
70 return;
71 }
72
73 var msg_id = this.comm.send({method: 'request_state'}, callbacks || this.widget_manager.callbacks());
74
75 // Promise that is resolved when a state is received
76 // from the back-end.
77 var that = this;
78 var received_state = new Promise(function(resolve) {
79 that._resolve_received_state[msg_id] = resolve;
80 });
81 return received_state;
82 },
83
84 set_comm_live: function(live) {
85 /**
86 * Change the comm_live state of the model.
87 */
88 if (this.comm_live === undefined || this.comm_live != live) {
89 this.comm_live = live;
90 this.trigger(live ? 'comm:live' : 'comm:dead', {model: this});
91 }
92 },
93
94 close: function(comm_closed) {
59 /**
95 /**
60 * Handle when a widget is closed.
96 * Close model
61 */
97 */
62 this.trigger('comm:close');
98 if (this.comm && !comm_closed) {
99 this.comm.close();
100 }
63 this.stopListening();
101 this.stopListening();
64 this.trigger('destroy', this);
102 this.trigger('destroy', this);
65 delete this.comm.model; // Delete ref so GC will collect widget model.
103 delete this.comm.model; // Delete ref so GC will collect widget model.
@@ -73,6 +111,14 b' define(["widgets/js/manager",'
73 });
111 });
74 },
112 },
75
113
114 _handle_comm_closed: function (msg) {
115 /**
116 * Handle when a widget is closed.
117 */
118 this.trigger('comm:close');
119 this.close(true);
120 },
121
76 _handle_comm_msg: function (msg) {
122 _handle_comm_msg: function (msg) {
77 /**
123 /**
78 * Handle incoming comm msg.
124 * Handle incoming comm msg.
@@ -81,15 +127,24 b' define(["widgets/js/manager",'
81 var that = this;
127 var that = this;
82 switch (method) {
128 switch (method) {
83 case 'update':
129 case 'update':
84 this.state_change = this.state_change.then(function() {
130 this.state_change = this.state_change
85 return that.set_state(msg.content.data.state);
131 .then(function() {
86 }).catch(utils.reject("Couldn't process update msg for model id '" + String(that.id) + "'", true));
132 return that.set_state(msg.content.data.state);
133 }).catch(utils.reject("Couldn't process update msg for model id '" + String(that.id) + "'", true))
134 .then(function() {
135 var parent_id = msg.parent_header.msg_id;
136 if (that._resolve_received_state[parent_id] !== undefined) {
137 that._resolve_received_state[parent_id].call();
138 delete that._resolve_received_state[parent_id];
139 }
140 }).catch(utils.reject("Couldn't resolve state request promise", true));
87 break;
141 break;
88 case 'custom':
142 case 'custom':
89 this.trigger('msg:custom', msg.content.data.content);
143 this.trigger('msg:custom', msg.content.data.content);
90 break;
144 break;
91 case 'display':
145 case 'display':
92 this.widget_manager.display_view(msg, this);
146 this.widget_manager.display_view(msg, this)
147 .catch(utils.reject('Could not process display view msg', true));
93 break;
148 break;
94 }
149 }
95 },
150 },
@@ -107,6 +162,17 b' define(["widgets/js/manager",'
107 }).catch(utils.reject("Couldn't set model state", true));
162 }).catch(utils.reject("Couldn't set model state", true));
108 },
163 },
109
164
165 get_state: function() {
166 // Get the serializable state of the model.
167 state = this.toJSON();
168 for (var key in state) {
169 if (state.hasOwnProperty(key)) {
170 state[key] = this._pack_models(state[key]);
171 }
172 }
173 return state;
174 },
175
110 _handle_status: function (msg, callbacks) {
176 _handle_status: function (msg, callbacks) {
111 /**
177 /**
112 * Handle status msgs.
178 * Handle status msgs.
@@ -356,16 +422,6 b' define(["widgets/js/manager",'
356 */
422 */
357 },
423 },
358
424
359 show: function(){
360 /**
361 * Show the widget-area
362 */
363 if (this.options && this.options.cell &&
364 this.options.cell.widget_area !== undefined) {
365 this.options.cell.widget_area.show();
366 }
367 },
368
369 send: function (content) {
425 send: function (content) {
370 /**
426 /**
371 * Send a custom msg associated with this view.
427 * Send a custom msg associated with this view.
@@ -387,6 +443,12 b' define(["widgets/js/manager",'
387 } else {
443 } else {
388 this.on('displayed', callback, context);
444 this.on('displayed', callback, context);
389 }
445 }
446 },
447
448 remove: function () {
449 // Raise a remove event when the view is removed.
450 WidgetView.__super__.remove.apply(this, arguments);
451 this.trigger('remove');
390 }
452 }
391 });
453 });
392
454
@@ -397,7 +459,6 b' define(["widgets/js/manager",'
397 * Public constructor
459 * Public constructor
398 */
460 */
399 DOMWidgetView.__super__.initialize.apply(this, [parameters]);
461 DOMWidgetView.__super__.initialize.apply(this, [parameters]);
400 this.on('displayed', this.show, this);
401 this.model.on('change:visible', this.update_visible, this);
462 this.model.on('change:visible', this.update_visible, this);
402 this.model.on('change:_css', this.update_css, this);
463 this.model.on('change:_css', this.update_css, this);
403
464
@@ -341,19 +341,26 b' class Widget(LoggingConfigurable):'
341 """Called when a msg is received from the front-end"""
341 """Called when a msg is received from the front-end"""
342 data = msg['content']['data']
342 data = msg['content']['data']
343 method = data['method']
343 method = data['method']
344 if not method in ['backbone', 'custom']:
345 self.log.error('Unknown front-end to back-end widget msg with method "%s"' % method)
346
344
347 # Handle backbone sync methods CREATE, PATCH, and UPDATE all in one.
345 # Handle backbone sync methods CREATE, PATCH, and UPDATE all in one.
348 if method == 'backbone' and 'sync_data' in data:
346 if method == 'backbone':
349 sync_data = data['sync_data']
347 if 'sync_data' in data:
350 self.set_state(sync_data) # handles all methods
348 sync_data = data['sync_data']
349 self.set_state(sync_data) # handles all methods
350
351 # Handle a state request.
352 elif method == 'request_state':
353 self.send_state()
351
354
352 # Handle a custom msg from the front-end
355 # Handle a custom msg from the front-end.
353 elif method == 'custom':
356 elif method == 'custom':
354 if 'content' in data:
357 if 'content' in data:
355 self._handle_custom_msg(data['content'])
358 self._handle_custom_msg(data['content'])
356
359
360 # Catch remainder.
361 else:
362 self.log.error('Unknown front-end to back-end widget msg with method "%s"' % method)
363
357 def _handle_custom_msg(self, content):
364 def _handle_custom_msg(self, content):
358 """Called when a custom msg is received."""
365 """Called when a custom msg is received."""
359 self._msg_callbacks(self, content)
366 self._msg_callbacks(self, content)
@@ -368,7 +375,7 b' class Widget(LoggingConfigurable):'
368 # Send the state after the user registered callbacks for trait changes
375 # Send the state after the user registered callbacks for trait changes
369 # have all fired (allows for user to validate values).
376 # have all fired (allows for user to validate values).
370 if self.comm is not None and name in self.keys:
377 if self.comm is not None and name in self.keys:
371 # Make sure this isn't information that the front-end just sent us.
378 # Make sure this isn't information that the front-end just sent us.
372 if self._should_send_property(name, new_value):
379 if self._should_send_property(name, new_value):
373 # Send new state to front-end
380 # Send new state to front-end
374 self.send_state(key=name)
381 self.send_state(key=name)
General Comments 0
You need to be logged in to leave comments. Login now