FIXED: Interference between 'span zoom' and mouse wheel- or keyboard-driven zoom
[oscopy.git] / oscopy / gtk_figure.py
blob402a6f096dfe15dafc9bb702b7178a685efdf610
2 import oscopy
3 import gtk
4 import gobject
5 import gui
6 from math import log10, sqrt
7 from matplotlib.backend_bases import LocationEvent
8 from matplotlib.backends.backend_gtkagg import FigureCanvasGTKAgg as FigureCanvas
9 from matplotlib.backends.backend_gtkagg import NavigationToolbar2GTKAgg as NavigationToolbar
10 from matplotlib.widgets import SpanSelector, RectangleSelector
12 IOSCOPY_COL_TEXT = 0 # Combo box text
13 IOSCOPY_COL_X10 = 1 # x10 mode status
14 IOSCOPY_COL_VIS = 2 # Combobox items sensitive
15 IOSCOPY_COL_SPAN = 3 # Span mode status
17 DEFAULT_ZOOM_FACTOR = 0.8
18 DEFAULT_PAN_FACTOR = 10
20 class Center(object):
21 # Stupid class to store center coordinates when zooming
22 def __init__(self):
23 self.x = 0
24 self.y = 0
26 class MyRectangleSelector(RectangleSelector):
27 """ FIXME: To be removed once upstream has merged PR #658
28 https://github.com/matplotlib/matplotlib/pull/658
29 """
31 def ignore(self, event):
32 'return ``True`` if *event* should be ignored'
33 # If RectangleSelector is not active :
34 if not self.active:
35 return True
37 # If canvas was locked
38 if not self.canvas.widgetlock.available(self):
39 return True
41 # Only do rectangle selection if event was triggered
42 # with a desired button
43 if self.validButtons is not None:
44 if not event.button in self.validButtons:
45 return True
47 # If no button was pressed yet ignore the event if it was out
48 # of the axes
49 if self.eventpress == None:
50 return event.inaxes!= self.ax
52 # If a button was pressed, check if the release-button is the
53 # same. If event is out of axis, limit the data coordinates to axes
54 # boundaries.
55 if event.button == self.eventpress.button and event.inaxes != self.ax:
56 (xdata, ydata) = self.ax.transData.inverted().transform_point((event.x, event.y))
57 x0, x1 = self.ax.get_xbound()
58 y0, y1 = self.ax.get_ybound()
59 xdata = max(x0, xdata)
60 xdata = min(x1, xdata)
61 ydata = max(y0, ydata)
62 ydata = min(y1, ydata)
63 event.xdata = xdata
64 event.ydata = ydata
65 return False
67 # If a button was pressed, check if the release-button is the
68 # same.
69 return (event.inaxes!=self.ax or
70 event.button != self.eventpress.button)
72 class IOscopy_GTK_Figure(oscopy.Figure):
73 def __init__(self, sigs={}, fig=None, title=''):
74 oscopy.Figure.__init__(self, None, fig)
75 self._TARGET_TYPE_SIGNAL = 10354
76 self._to_figure = [("oscopy-signals", gtk.TARGET_SAME_APP,\
77 self._TARGET_TYPE_SIGNAL)]
79 w = gtk.Window()
81 w.set_title(title)
82 hbox1 = gtk.HBox() # The window
83 vbox1 = gtk.VBox() # The Graphs
84 hbox1.pack_start(vbox1)
85 w.add(hbox1)
86 canvas = FigureCanvas(self)
87 canvas.mpl_connect('button_press_event', self._button_press)
88 canvas.mpl_connect('scroll_event', self._mouse_scroll)
89 canvas.mpl_connect('axes_enter_event', self._axes_enter)
90 canvas.mpl_connect('axes_leave_event', self._axes_leave)
91 canvas.mpl_connect('figure_enter_event', self._figure_enter)
92 canvas.mpl_connect('figure_leave_event', self._figure_leave)
93 canvas.mpl_connect('key_press_event', self._key_press)
94 w.connect('delete-event', lambda w, e: w.hide() or True)
95 w.drag_dest_set(gtk.DEST_DEFAULT_MOTION |\
96 gtk.DEST_DEFAULT_HIGHLIGHT |\
97 gtk.DEST_DEFAULT_DROP,
98 self._to_figure, gtk.gdk.ACTION_COPY)
100 vbox1.pack_start(canvas)
102 toolbar = NavigationToolbar(canvas, w)
103 vbox1.pack_start(toolbar, False, False)
105 vbox2 = gtk.VBox() # The right-side menu
106 store = gtk.ListStore(gobject.TYPE_STRING, # String displayed
107 gobject.TYPE_BOOLEAN, # x10 mode status
108 gobject.TYPE_BOOLEAN, # Combobox item sensitive
109 gobject.TYPE_BOOLEAN, # Span mode status
110 # gobject.TYPE_FLOAT, # Zoom factor
112 iter = store.append([_('All Graphs'), False, True, False])
113 for i in xrange(4):
114 iter = store.append([_('Graph %d') % (i + 1), False, True if i < len(self.graphs) else False, False])
115 self._cbx_store = store
117 graphs_cbx = gtk.ComboBox(store)
118 cell = gtk.CellRendererText()
119 graphs_cbx.pack_start(cell, True)
120 graphs_cbx.add_attribute(cell, 'text', IOSCOPY_COL_TEXT)
121 graphs_cbx.add_attribute(cell, 'sensitive', IOSCOPY_COL_VIS)
122 graphs_cbx.set_active(0)
123 vbox2.pack_start(graphs_cbx, False, False)
125 x10_toggle_btn = gtk.ToggleButton('x10 mode')
126 x10_toggle_btn.set_mode(True)
127 x10_toggle_btn.connect('toggled', self.x10_toggle_btn_toggled,
128 graphs_cbx, store)
130 span_toggle_btn = gtk.ToggleButton(_('Span'))
131 span_toggle_btn.set_mode(True)
132 span_toggle_btn.connect('toggled', self.span_toggle_btn_toggled,
133 graphs_cbx, store)
135 self._cbx = graphs_cbx
136 self._btn = x10_toggle_btn
138 graphs_cbx.connect('changed', self.graphs_cbx_changed, x10_toggle_btn,
139 span_toggle_btn, store)
140 vbox2.pack_start(x10_toggle_btn, False, False)
141 vbox2.pack_start(span_toggle_btn, False, False)
143 hbox1.pack_start(vbox2, False, False)
145 w.resize(640, 480)
146 w.show_all()
147 self.window = w
148 if sigs:
149 self.add(sigs)
150 # # Update canvas for SpanSelector of Graphs
151 for gr in self.graphs:
152 if hasattr(gr, 'span'):
153 gr.span.new_axes(gr)
155 def set_layout(self, layout='quad'):
156 oscopy.Figure.set_layout(self, layout)
157 iter = self._cbx_store.get_iter_first()
158 for g in self.graphs:
159 iter = self._cbx_store.iter_next(iter)
160 if self._layout == 'horiz':
161 g.span = SpanSelector(g, g.onselect, 'horizontal',
162 useblit=True)
163 g.span.visible = self._cbx_store.get_value(iter, IOSCOPY_COL_SPAN)
164 elif self._layout == 'vert':
165 g.span = SpanSelector(g, g.onselect, 'vertical',
166 useblit=True)
167 g.span.visible = self._cbx_store.get_value(iter, IOSCOPY_COL_SPAN)
168 elif self._layout == 'quad':
169 g.span = MyRectangleSelector(g, g.onselect, rectprops=dict(facecolor='red', edgecolor = 'black', alpha=0.5, fill=True),
170 useblit=True)
171 g.span.active = self._cbx_store.get_value(iter, IOSCOPY_COL_SPAN)
173 def graphs_cbx_changed(self, graphs_cbx, x10_toggle_btn, span_toggle_btn, store):
174 iter = graphs_cbx.get_active_iter()
175 if store.get_string_from_iter(iter) == '0':
176 # Do all the graphs have same state?
177 store.iter_next(iter)
178 val = store.get_value(iter, IOSCOPY_COL_X10)
179 while iter is not None:
180 if val != store.get_value(iter, IOSCOPY_COL_X10):
181 # Yes, set the button into inconsistent state
182 x10_toggle_btn.set_inconsistent(True)
183 break
184 iter = store.iter_next(iter)
185 iter = store.get_iter_first()
186 store.iter_next(iter)
187 val = store.get_value(iter, IOSCOPY_COL_SPAN)
188 while iter is not None:
189 if val != store.get_value(iter, IOSCOPY_COL_SPAN):
190 # Yes, set the button into inconsistent state
191 span_toggle_btn.set_inconsistent(True)
192 break
193 iter = store.iter_next(iter)
194 else:
195 x10_toggle_btn.set_inconsistent(False)
196 x10_toggle_btn.set_active(store.get_value(iter, IOSCOPY_COL_X10))
197 span_toggle_btn.set_inconsistent(False)
198 span_toggle_btn.set_active(store.get_value(iter, IOSCOPY_COL_SPAN))
200 def x10_toggle_btn_toggled(self, x10_toggle_btn, graphs_cbx, store):
201 center = None
202 iter = graphs_cbx.get_active_iter()
203 a = x10_toggle_btn.get_active()
204 val = -.1 if a else -1 # x10 zoom
205 x10_toggle_btn.set_inconsistent(False)
206 if iter is not None:
207 store.set_value(iter, IOSCOPY_COL_X10, a)
208 grnum = int(store.get_string_from_iter(iter))
209 if store.get_string_from_iter(iter) == '0':
210 # Set the value for all graphs
211 iter = store.iter_next(iter)
212 while iter is not None:
213 store.set_value(iter, IOSCOPY_COL_X10, a)
214 grnum = int(store.get_string_from_iter(iter))
215 if grnum > len(self.graphs):
216 break
217 self._zoom(grnum, center, val)
218 iter = store.iter_next(iter)
219 else:
220 self._zoom(grnum, center, val)
221 self.canvas.draw()
223 def span_toggle_btn_toggled(self, span_toggle_btn, graphs_cbx, store):
224 iter = graphs_cbx.get_active_iter()
225 a = span_toggle_btn.get_active()
226 span_toggle_btn.set_inconsistent(False)
227 if iter is not None:
228 store.set_value(iter, IOSCOPY_COL_SPAN, a)
229 grnum = int(store.get_string_from_iter(iter))
230 if store.get_string_from_iter(iter) == '0':
231 # Set the value for all graphs
232 iter = store.iter_next(iter)
233 while iter is not None:
234 store.set_value(iter, IOSCOPY_COL_SPAN, a)
235 grnum = int(store.get_string_from_iter(iter))
236 if grnum > len(self.graphs):
237 break
238 if hasattr(self.graphs[grnum - 1].span, 'active'):
239 self.graphs[grnum - 1].span.active = a
240 elif hasattr(self.graphs[grnum - 1].span, 'visible'):
241 self.graphs[grnum - 1].span.visible = a
242 iter = store.iter_next(iter)
243 else:
244 if hasattr(self.graphs[grnum - 1].span, 'active'):
245 self.graphs[grnum - 1].span.active = a
246 elif hasattr(self.graphs[grnum - 1].span, 'visible'):
247 self.graphs[grnum - 1].span.visible = a
248 self.canvas.draw()
250 def _key_press(self, event):
251 if event.inaxes is not None:
252 g = event.inaxes
253 grnum = self.graphs.index(g) + 1
254 center = Center()
255 center.x = event.xdata
256 center.y = event.ydata
257 if event.key == 'z':
258 self._zoom(grnum, center, DEFAULT_ZOOM_FACTOR)
259 self.canvas.draw_idle()
260 elif event.key == 'Z':
261 self._zoom(grnum, center, 1 / DEFAULT_ZOOM_FACTOR)
262 self.canvas.draw_idle()
263 elif event.key == 'l':
264 result = g.bbox.translated(-DEFAULT_PAN_FACTOR, 0).transformed(g.transData.inverted())
265 g.set_xlim(*result.intervalx)
266 g.set_ylim(*result.intervaly)
267 self.canvas.draw_idle()
268 elif event.key == 'r':
269 result = g.bbox.translated(DEFAULT_PAN_FACTOR, 0).transformed(g.transData.inverted())
270 g.set_xlim(*result.intervalx)
271 g.set_ylim(*result.intervaly)
272 self.canvas.draw_idle()
273 return True
275 def _zoom(self, grnum, center=None, factor=1):
276 # In which layout are we (horiz, vert, quad ?)
277 layout = self.layout
278 gr = self.graphs[grnum - 1]
279 [xmin, xmax, ymin, ymax] = [None for x in xrange(4)]
280 [(xmin_cur, xmax_cur), (ymin_cur, ymax_cur)] = gr.range
281 [(xmin_new, xmax_new), (ymin_new, ymax_new)] = gr.range
283 # Get the bounds of the data (min, max)
284 if layout == 'horiz' or layout == 'quad':
285 (xmin, xmax) = (gr.dataLim.xmin, gr.dataLim.xmax)
287 if layout == 'vert' or layout == 'quad':
288 (ymin, ymax) = (gr.dataLim.ymin, gr.dataLim.ymax)
290 # Calculate the x10 (linear or log scale ?) and set it
291 sc = gr.scale
292 logx = True if sc == 'logx' or sc == 'loglog' else False
293 logy = True if sc == 'logy' or sc == 'loglog' else False
294 if xmin is not None and xmax is not None:
295 if logx:
296 cx = log10(center.x) if center is not None else None
297 curb = (log10(xmin_cur), log10(xmax_cur)) # Current bounds
298 datab = (log10(xmin), log10(xmax)) # Data bounds
299 newb = self._compute_zoom_range(curb, datab, cx, factor)
300 (xmin_new, xmax_new) = newb # New bounds
301 xmin_new = pow(10, xmin_new)
302 xmax_new = pow(10, xmax_new)
303 else:
304 cx = center.x if center is not None else None
305 curb = (xmin_cur, xmax_cur) # Current bounds
306 datab = (xmin, xmax) # Data bounds
307 newb = self._compute_zoom_range(curb, datab, cx, factor)
308 (xmin_new, xmax_new) = newb # New bounds
309 gr.set_xbound(xmin_new, xmax_new)
311 if ymin is not None and ymax is not None:
312 if logy:
313 cy = log10(center.y) if center is not None else None
314 curb = (log10(ymin_cur), log10(ymax_cur)) # Current bounds
315 datab = (log10(ymin), log10(ymax)) # Data bounds
316 newb = self._compute_zoom_range(curb, datab, cy, factor)
317 (ymin_new, ymax_new) = newb # New bounds
318 ymin_new = pow(10, ymin_new)
319 ymax_new = pow(10, ymax_new)
320 else:
321 cy = center.y if center is not None else None
322 curb = (ymin_cur, ymax_cur) # Current bounds
323 datab = (ymin, ymax) # Data bounds
324 newb = self._compute_zoom_range(curb, datab, cy, factor)
325 (ymin_new, ymax_new) = newb # New bounds
326 gr.set_ybound(ymin_new, ymax_new)
328 def _compute_zoom_range(self, curb, datab, center=None, factor=-1):
329 # Zoom is relative to current bounds when factor > 0
330 # otherwise is relative to data bounds
331 if not factor:
332 return curb
333 (data_min, data_max) = datab
334 (min_cur, max_cur) = curb if factor > 0 else datab
335 if factor == -1:
336 return (data_min, data_max)
337 curf = (max_cur - min_cur) / (data_max - data_min)
338 factor = factor * curf
339 if center is None:
340 center = (max_cur + min_cur) / 2
341 pos = (center - min_cur) / (max_cur - min_cur)
342 min_new = center - (data_max - data_min) * (abs(factor) * pos)
343 max_new = center + (data_max - data_min) * (abs(factor) * (1 - pos))
344 if min_new > max_new:
345 return (max_new, min_new)
346 else:
347 return (min_new, max_new)
349 def drag_data_received_cb(self, widget, drag_context, x, y, selection,\
350 target_type, time, ctxtsignals):
351 # Event handling issue: this drag and drop callback is
352 # processed before matplotlib callback _axes_enter. Therefore
353 # when dropping, self._current_graph is not valid: it contains
354 # the last graph.
355 # The workaround is to retrieve the Graph by creating a Matplotlib
356 # LocationEvent considering inverse 'y' coordinates
357 if target_type == self._TARGET_TYPE_SIGNAL:
358 canvas = self.canvas
359 my_y = canvas.allocation.height - y
360 event = LocationEvent('axes_enter_event', canvas, x, my_y)
361 signals = {}
362 for name in selection.data.split():
363 signals[name] = ctxtsignals[name]
364 if event.inaxes is not None:
365 # Graph not found
366 event.inaxes.insert(signals)
367 self.canvas.draw()
369 def add(self, args):
370 oscopy.Figure.add(self, args)
371 store = self._cbx_store
372 iter = store.get_iter_first()
373 iter = store.iter_next(iter) # First item always sensitive
374 while iter is not None:
375 grnum = int(store.get_string_from_iter(iter))
376 if grnum > len(self.graphs):
377 store.set_value(iter, IOSCOPY_COL_VIS, False)
378 else:
379 store.set_value(iter, IOSCOPY_COL_VIS, True)
380 iter = store.iter_next(iter)
382 def delete(self, args):
383 oscopy.Figure.delete(self, args)
384 store = self._cbx_store
385 iter = store.get_iter_first()
386 iter = store.iter_next(iter) # First item always sensitive
387 while iter is not None:
388 grnum = int(store.get_string_from_iter(iter))
389 if grnum > len(self.graphs):
390 store.set_value(iter, IOSCOPY_COL_VIS, False)
391 else:
392 store.set_value(iter, IOSCOPY_COL_VIS, True)
393 iter = store.iter_next(iter)
395 def _button_press(self, event):
396 if event.button == 3:
397 menu = self._create_figure_popup_menu(event.canvas.figure, event.inaxes)
398 menu.show_all()
399 menu.popup(None, None, None, event.button, event.guiEvent.time)
401 def _mouse_scroll(self, event):
402 if event.button == 'up':
403 if event.inaxes is None:
404 return False
405 g = event.inaxes
406 grnum = self.graphs.index(g) + 1
407 center = Center()
408 center.x = event.xdata
409 center.y = event.ydata
410 self._zoom(grnum, center, DEFAULT_ZOOM_FACTOR)
411 self.canvas.draw_idle()
412 elif event.button == 'down':
413 if event.inaxes is None:
414 return False
415 g = event.inaxes
416 grnum = self.graphs.index(g) + 1
417 center = Center()
418 center.x = event.xdata
419 center.y = event.ydata
420 self._zoom(grnum, center, 1. / DEFAULT_ZOOM_FACTOR)
421 self.canvas.draw_idle()
422 return True
424 def _axes_enter(self, event):
425 # self._figure_enter(event)
426 # self._current_graph = event.inaxes
428 # axes_num = event.canvas.figure.axes.index(event.inaxes) + 1
429 # fig_num = self._ctxt.figures.index(self._current_figure) + 1
430 # self._app_exec('%%oselect %d-%d' % (fig_num, axes_num))
431 pass
433 def _axes_leave(self, event):
434 # Unused for better user interaction
435 # self._current_graph = None
436 pass
438 def _figure_enter(self, event):
439 # self._current_figure = event.canvas.figure
440 # if hasattr(event, 'inaxes') and event.inaxes is not None:
441 # axes_num = event.canvas.figure.axes.index(event.inaxes) + 1
442 # else:
443 # axes_num = 1
444 # fig_num = self._ctxt.figures.index(self._current_figure) + 1
445 # self._app_exec('%%oselect %d-%d' % (fig_num, axes_num))
446 self.canvas.grab_focus()
447 pass
449 def _figure_leave(self, event):
450 # self._current_figure = None
451 pass
453 def _create_figure_popup_menu(self, figure, graph):
454 figmenu = gui.menus.FigureMenu()
455 return figmenu.create_menu(figure, graph)
458 layout = property(oscopy.Figure.get_layout, set_layout)