Skip to content

Commit 53db24c

Browse files
committed
Make image as part of the widget model
1 parent 20aa47d commit 53db24c

File tree

3 files changed

+121
-13
lines changed

3 files changed

+121
-13
lines changed

ipympl/backend_nbagg.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,18 @@
44
import json
55
from base64 import b64encode
66

7+
try:
8+
from collections.abc import Iterable
9+
except ImportError:
10+
# Python 2.7
11+
from collections import Iterable
12+
713
import matplotlib
14+
import numpy as np
815
from IPython import get_ipython
916
from IPython import version_info as ipython_version_info
1017
from IPython.display import HTML, display
18+
from ipython_genutils.py3compat import string_types
1119
from ipywidgets import DOMWidget, widget_serialization
1220
from matplotlib import is_interactive, rcParams
1321
from matplotlib._pylab_helpers import Gcf
@@ -18,7 +26,9 @@
1826
NavigationToolbar2WebAgg,
1927
TimerTornado,
2028
)
29+
from PIL import Image
2130
from traitlets import (
31+
Any,
2232
Bool,
2333
CaselessStrEnum,
2434
CInt,
@@ -142,6 +152,14 @@ class Canvas(DOMWidget, FigureCanvasWebAggCore):
142152
resizable = Bool(True).tag(sync=True)
143153
capture_scroll = Bool(False).tag(sync=True)
144154

155+
# This is a very special widget trait:
156+
# We set "sync=True" because we want ipywidgets to consider this
157+
# as part of the widget state, but we overwrite send_state so that
158+
# it never sync the value with the front-end, the front-end keeps its
159+
# own value.
160+
# This will still be used by ipywidgets in the case of embedding.
161+
_data_url = Any(None).tag(sync=True)
162+
145163
_width = CInt().tag(sync=True)
146164
_height = CInt().tag(sync=True)
147165

@@ -172,12 +190,34 @@ def __init__(self, figure, *args, **kwargs):
172190

173191
self.on_msg(self._handle_message)
174192

193+
# This will stay True for cases where there is no
194+
# front-end (e.g. nbconvert --execute)
195+
self.syncing_data_url = True
196+
197+
# Overwrite ipywidgets's send_state so we don't sync the data_url
198+
def send_state(self, key=None):
199+
if key is None:
200+
keys = self.keys
201+
elif isinstance(key, string_types):
202+
keys = [key]
203+
elif isinstance(key, Iterable):
204+
keys = key
205+
206+
if not self.syncing_data_url:
207+
keys = [k for k in keys if k != '_data_url']
208+
209+
DOMWidget.send_state(self, key=keys)
210+
175211
def _handle_message(self, object, content, buffers):
176212
# Every content has a "type".
177213
if content['type'] == 'closing':
178214
self._closed = True
179215

180216
elif content['type'] == 'initialized':
217+
# We stop syncing data url, the front-end is there and
218+
# ready to receive diffs
219+
self.syncing_data_url = False
220+
181221
_, _, w, h = self.figure.bbox.bounds
182222
self.manager.resize(w, h)
183223

@@ -214,6 +254,19 @@ def send_json(self, content):
214254
self.send({'data': json.dumps(content)})
215255

216256
def send_binary(self, data):
257+
# TODO we should maybe rework the FigureCanvasWebAggCore implementation
258+
# so that it has a "refresh" method that we can overwrite
259+
260+
# Update _data_url
261+
if self.syncing_data_url:
262+
data = self._last_buff.view(dtype=np.uint8).reshape(
263+
(*self._last_buff.shape, 4)
264+
)
265+
with io.BytesIO() as png:
266+
Image.fromarray(data).save(png, format="png")
267+
self._data_url = b64encode(png.getvalue()).decode('utf-8')
268+
269+
# Actually send the data
217270
self.send({'data': '{"type": "binary"}'}, buffers=[data])
218271

219272
def new_timer(self, *args, **kwargs):
@@ -229,11 +282,11 @@ def _repr_mimebundle_(self, **kwargs):
229282

230283
buf = io.BytesIO()
231284
self.figure.savefig(buf, format='png', dpi='figure')
232-
data_url = b64encode(buf.getvalue()).decode('utf-8')
285+
self._data_url = b64encode(buf.getvalue()).decode('utf-8')
233286

234287
data = {
235288
'text/plain': plaintext,
236-
'image/png': data_url,
289+
'image/png': self._data_url,
237290
'application/vnd.jupyter.widget-view+json': {
238291
'version_major': 2,
239292
'version_minor': 0,

src/mpl_widget.ts

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import {
22
DOMWidgetModel,
33
DOMWidgetView,
4+
WidgetModel,
45
ISerializers,
56
unpack_models,
67
} from '@jupyter-widgets/base';
8+
79
import * as utils from './utils';
810

911
import { MODULE_VERSION } from './version';
@@ -14,8 +16,9 @@ export class MPLCanvasModel extends DOMWidgetModel {
1416
requested_size: Array<number> | null;
1517
resize_requested: boolean;
1618
ratio: number;
17-
waiting: any;
19+
waiting_for_image: boolean;
1820
image: HTMLImageElement;
21+
1922
defaults() {
2023
return {
2124
...super.defaults(),
@@ -32,6 +35,7 @@ export class MPLCanvasModel extends DOMWidgetModel {
3235
toolbar_position: 'horizontal',
3336
resizable: true,
3437
capture_scroll: false,
38+
_data_url: null,
3539
_width: 0,
3640
_height: 0,
3741
_figure_label: 'Figure',
@@ -70,6 +74,9 @@ export class MPLCanvasModel extends DOMWidgetModel {
7074
this.requested_size = null;
7175
this.resize_requested = false;
7276
this.ratio = (window.devicePixelRatio || 1) / backingStore;
77+
78+
this.resize_canvas(this.get('_width'), this.get('_height'));
79+
7380
this._init_image();
7481

7582
this.on('msg:custom', this.on_comm_message.bind(this));
@@ -78,10 +85,30 @@ export class MPLCanvasModel extends DOMWidgetModel {
7885
view.update_canvas();
7986
});
8087
});
88+
this.on('comm_live_update', this.update_disabled.bind(this));
89+
90+
this.update_disabled();
8191

8292
this.send_initialization_message();
8393
}
8494

95+
get disabled(): boolean {
96+
return !this.comm_live;
97+
}
98+
99+
update_disabled(): void {
100+
this.set('resizable', !this.disabled);
101+
}
102+
103+
sync(method: string, model: WidgetModel, options: any = {}) {
104+
// Make sure we don't sync the data_url, we don't need it to be synced
105+
if (options.attrs) {
106+
delete options.attrs['_data_url'];
107+
}
108+
109+
super.sync(method, model, options);
110+
}
111+
85112
send_message(type: string, message: { [index: string]: any } = {}) {
86113
message['type'] = type;
87114

@@ -96,15 +123,15 @@ export class MPLCanvasModel extends DOMWidgetModel {
96123
});
97124
}
98125

99-
this.send_message('send_image_mode');
100126
this.send_message('refresh');
127+
this.send_message('send_image_mode');
101128

102129
this.send_message('initialized');
103130
}
104131

105132
send_draw_message() {
106-
if (!this.waiting) {
107-
this.waiting = true;
133+
if (!this.waiting_for_image) {
134+
this.waiting_for_image = true;
108135
this.send_message('draw');
109136
}
110137
}
@@ -160,8 +187,8 @@ export class MPLCanvasModel extends DOMWidgetModel {
160187
}
161188

162189
resize_canvas(width: number, height: number) {
163-
this.offscreen_canvas.setAttribute('width', `${width * this.ratio}`);
164-
this.offscreen_canvas.setAttribute('height', `${height * this.ratio}`);
190+
this.offscreen_canvas.width = width * this.ratio;
191+
this.offscreen_canvas.height = height * this.ratio;
165192
}
166193

167194
handle_rubberband(msg: any) {
@@ -204,7 +231,9 @@ export class MPLCanvasModel extends DOMWidgetModel {
204231

205232
this.image.src = image_url;
206233

207-
this.waiting = false;
234+
this.set('_data_url', this.offscreen_canvas.toDataURL());
235+
236+
this.waiting_for_image = false;
208237
}
209238

210239
handle_history_buttons(msg: any) {
@@ -240,7 +269,8 @@ export class MPLCanvasModel extends DOMWidgetModel {
240269
}
241270

242271
_init_image() {
243-
this.image = document.createElement('img');
272+
this.image = new Image();
273+
244274
this.image.onload = () => {
245275
if (this.get('_image_mode') === 'full') {
246276
// Full images could contain transparency (where diff images
@@ -259,6 +289,12 @@ export class MPLCanvasModel extends DOMWidgetModel {
259289
view.update_canvas();
260290
});
261291
};
292+
293+
const dataUrl = this.get('_data_url');
294+
295+
if (dataUrl !== null) {
296+
this.image.src = dataUrl;
297+
}
262298
}
263299

264300
_for_each_view(callback: any) {
@@ -285,12 +321,12 @@ export class MPLCanvasView extends DOMWidgetView {
285321
context: CanvasRenderingContext2D;
286322
top_canvas: HTMLCanvasElement;
287323
top_context: CanvasRenderingContext2D;
288-
waiting: boolean;
289324
footer: HTMLDivElement;
290325
model: MPLCanvasModel;
291326
private _key: string | null;
292327
private _resize_event: (event: MouseEvent) => void;
293328
private _stop_resize_event: () => void;
329+
294330
render() {
295331
this.resizing = false;
296332
this.resize_handle_size = 20;
@@ -313,8 +349,6 @@ export class MPLCanvasView extends DOMWidgetView {
313349
window.addEventListener('mousemove', this._resize_event);
314350
window.addEventListener('mouseup', this._stop_resize_event);
315351

316-
this.waiting = false;
317-
318352
return this.create_child_view(this.model.get('toolbar')).then(
319353
(toolbar_view) => {
320354
this.toolbar_view = toolbar_view;

src/toolbar_widget.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ export class ToolbarView extends DOMWidgetView {
2525
toggle_button: HTMLButtonElement;
2626
toolbar: HTMLDivElement;
2727
buttons: { [index: string]: HTMLButtonElement };
28+
29+
initialize(parameters: any) {
30+
super.initialize(parameters);
31+
32+
this.on('comm_live_update', this.update_disabled.bind(this));
33+
}
34+
2835
render(): void {
2936
this.el.classList.add(
3037
'jupyter-widgets',
@@ -100,6 +107,20 @@ export class ToolbarView extends DOMWidgetView {
100107
this.set_orientation(this.el);
101108
this.set_orientation(this.toolbar);
102109
this.set_buttons_style();
110+
111+
this.update_disabled();
112+
}
113+
114+
get disabled(): boolean {
115+
return !this.model.comm_live;
116+
}
117+
118+
update_disabled(): void {
119+
// Disable buttons
120+
this.toggle_button.disabled = this.disabled;
121+
if (this.disabled) {
122+
this.toolbar.style.display = 'none';
123+
}
103124
}
104125

105126
set_orientation(el: HTMLElement): void {

0 commit comments

Comments
 (0)