diff --git a/CHANGELOG b/CHANGELOG index 00791883a7e5..ce177318dce1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,10 @@ + 2015-11-16 Levels passed to contour(f) and tricontour(f) must be in increasing order. +2015-10-21 Added TextBox widget + + 2015-10-21 Added get_ticks_direction() 2015-02-27 Added the rcParam 'image.composite_image' to permit users diff --git a/doc/users/whats_new.rst b/doc/users/whats_new.rst index ab6ab4f39b71..645dfa960506 100644 --- a/doc/users/whats_new.rst +++ b/doc/users/whats_new.rst @@ -244,6 +244,13 @@ Some parameters have been added, others have been improved. Widgets ------- +Added TextBox Widget +```````````````````` + +Added a widget that allows text entry by reading key events when it is active. +Text caret in text box is visible when it is active, can be moved using arrow keys and mouse + + Active state of Selectors ````````````````````````` diff --git a/examples/widgets/textbox.py b/examples/widgets/textbox.py new file mode 100644 index 000000000000..271675a9757d --- /dev/null +++ b/examples/widgets/textbox.py @@ -0,0 +1,23 @@ + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.widgets import TextBox +fig, ax = plt.subplots() +plt.subplots_adjust(bottom=0.2) +t = np.arange(-2.0, 2.0, 0.001) +s = t ** 2 +initial_text = "t ** 2" +l, = plt.plot(t, s, lw=2) + + +def submit(text): + ydata = eval(text) + l.set_ydata(ydata) + ax.set_ylim(np.min(ydata), np.max(ydata)) + plt.draw() + +axbox = plt.axes([0.1, 0.05, 0.8, 0.075]) +text_box = TextBox(axbox, 'Evaluate', initial=initial_text) +text_box.on_submit(submit) + +plt.show() diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index ad008c4f0d70..560b65eee2c9 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -17,6 +17,7 @@ from matplotlib.externals.six.moves import zip import numpy as np +from matplotlib import rcParams from .mlab import dist from .patches import Circle, Rectangle, Ellipse @@ -622,6 +623,299 @@ def disconnect(self, cid): pass +class TextBox(AxesWidget): + """ + A GUI neutral text input box. + + For the text box to remain responsive you must keep a reference to it. + + The following attributes are accessible: + + *ax* + The :class:`matplotlib.axes.Axes` the button renders into. + + *label* + A :class:`matplotlib.text.Text` instance. + + *color* + The color of the text box when not hovering. + + *hovercolor* + The color of the text box when hovering. + + Call :meth:`on_text_change` to be updated whenever the text changes. + + Call :meth:`on_submit` to be updated whenever the user hits enter or + leaves the text entry field. + """ + + def __init__(self, ax, label, initial='', + color='.95', hovercolor='1', label_pad=.01): + """ + Parameters + ---------- + ax : matplotlib.axes.Axes + The :class:`matplotlib.axes.Axes` instance the button + will be placed into. + + label : str + Label for this text box. Accepts string. + + initial : str + Initial value in the text box + + color : color + The color of the box + + hovercolor : color + The color of the box when the mouse is over it + + label_pad : float + the distance between the label and the right side of the textbox + """ + AxesWidget.__init__(self, ax) + + self.DIST_FROM_LEFT = .05 + + self.params_to_disable = [] + for key in rcParams.keys(): + if u'keymap' in key: + self.params_to_disable += [key] + + self.text = initial + self.label = ax.text(-label_pad, 0.5, label, + verticalalignment='center', + horizontalalignment='right', + transform=ax.transAxes) + self.text_disp = self._make_text_disp(self.text) + + self.cnt = 0 + self.change_observers = {} + self.submit_observers = {} + + # If these lines are removed, the cursor won't appear the first + # time the box is clicked: + self.ax.set_xlim(0, 1) + self.ax.set_ylim(0, 1) + + self.cursor_index = 0 + + # Because this is initialized, _render_cursor + # can assume that cursor exists. + self.cursor = self.ax.vlines(0, 0, 0) + self.cursor.set_visible(False) + + self.connect_event('button_press_event', self._click) + self.connect_event('button_release_event', self._release) + self.connect_event('motion_notify_event', self._motion) + self.connect_event('key_press_event', self._keypress) + self.connect_event('resize_event', self._resize) + ax.set_navigate(False) + ax.set_facecolor(color) + ax.set_xticks([]) + ax.set_yticks([]) + self.color = color + self.hovercolor = hovercolor + + self._lastcolor = color + + self.capturekeystrokes = False + + def _make_text_disp(self, string): + return self.ax.text(self.DIST_FROM_LEFT, 0.5, string, + verticalalignment='center', + horizontalalignment='left', + transform=self.ax.transAxes) + + def _rendercursor(self): + # this is a hack to figure out where the cursor should go. + # we draw the text up to where the cursor should go, measure + # and save its dimensions, draw the real text, then put the cursor + # at the saved dimensions + + widthtext = self.text[:self.cursor_index] + no_text = False + if(widthtext == "" or widthtext == " " or widthtext == " "): + no_text = widthtext == "" + widthtext = "," + + wt_disp = self._make_text_disp(widthtext) + + self.ax.figure.canvas.draw() + bb = wt_disp.get_window_extent() + inv = self.ax.transData.inverted() + bb = inv.transform(bb) + wt_disp.set_visible(False) + if no_text: + bb[1, 0] = bb[0, 0] + # hack done + self.cursor.set_visible(False) + + self.cursor = self.ax.vlines(bb[1, 0], bb[0, 1], bb[1, 1]) + self.ax.figure.canvas.draw() + + def _notify_submit_observers(self): + for cid, func in six.iteritems(self.submit_observers): + func(self.text) + + def _release(self, event): + if self.ignore(event): + return + if event.canvas.mouse_grabber != self.ax: + return + event.canvas.release_mouse(self.ax) + + def _keypress(self, event): + if self.ignore(event): + return + if self.capturekeystrokes: + key = event.key + + if(len(key) == 1): + self.text = (self.text[:self.cursor_index] + key + + self.text[self.cursor_index:]) + self.cursor_index += 1 + elif key == "right": + if self.cursor_index != len(self.text): + self.cursor_index += 1 + elif key == "left": + if self.cursor_index != 0: + self.cursor_index -= 1 + elif key == "home": + self.cursor_index = 0 + elif key == "end": + self.cursor_index = len(self.text) + elif(key == "backspace"): + if self.cursor_index != 0: + self.text = (self.text[:self.cursor_index - 1] + + self.text[self.cursor_index:]) + self.cursor_index -= 1 + elif(key == "delete"): + if self.cursor_index != len(self.text): + self.text = (self.text[:self.cursor_index] + + self.text[self.cursor_index + 1:]) + + self.text_disp.remove() + self.text_disp = self._make_text_disp(self.text) + self._rendercursor() + for cid, func in six.iteritems(self.change_observers): + func(self.text) + if key == "enter": + self._notify_submit_observers() + + def begin_typing(self, x): + self.capturekeystrokes = True + #disable command keys so that the user can type without + #command keys causing figure to be saved, etc + self.reset_params = {} + for key in self.params_to_disable: + self.reset_params[key] = rcParams[key] + rcParams[key] = [] + + def stop_typing(self): + notifysubmit = False + # because _notify_submit_users might throw an error in the + # user's code, we only want to call it once we've already done + # our cleanup. + if self.capturekeystrokes: + #since the user is no longer typing, + #reactivate the standard command keys + for key in self.params_to_disable: + rcParams[key] = self.reset_params[key] + notifysubmit = True + self.capturekeystrokes = False + self.cursor.set_visible(False) + self.ax.figure.canvas.draw() + if notifysubmit: + self._notify_submit_observers() + + def position_cursor(self, x): + #now, we have to figure out where the cursor goes. + #approximate it based on assuming all characters the same length + if len(self.text) == 0: + self.cursor_index = 0 + else: + bb = self.text_disp.get_window_extent() + + trans = self.ax.transData + inv = self.ax.transData.inverted() + bb = trans.transform(inv.transform(bb)) + + text_start = bb[0, 0] + text_end = bb[1, 0] + + ratio = (x - text_start) / (text_end - text_start) + + if ratio < 0: + ratio = 0 + if ratio > 1: + ratio = 1 + + self.cursor_index = int(len(self.text) * ratio) + + self._rendercursor() + + def _click(self, event): + if self.ignore(event): + return + if event.inaxes != self.ax: + self.stop_typing() + return + if not self.eventson: + return + if event.canvas.mouse_grabber != self.ax: + event.canvas.grab_mouse(self.ax) + if not(self.capturekeystrokes): + self.begin_typing(event.x) + self.position_cursor(event.x) + + def _resize(self, event): + self.stop_typing() + + def _motion(self, event): + if self.ignore(event): + return + if event.inaxes == self.ax: + c = self.hovercolor + else: + c = self.color + if c != self._lastcolor: + self.ax.set_facecolor(c) + self._lastcolor = c + if self.drawon: + self.ax.figure.canvas.draw() + + def on_text_change(self, func): + """ + When the text changes, call this *func* with event. + + A connection id is returned which can be used to disconnect. + """ + cid = self.cnt + self.change_observers[cid] = func + self.cnt += 1 + return cid + + def on_submit(self, func): + """ + When the user hits enter or leaves the submision box, call this + *func* with event. + + A connection id is returned which can be used to disconnect. + """ + cid = self.cnt + self.submit_observers[cid] = func + self.cnt += 1 + return cid + + def disconnect(self, cid): + """remove the observer with connection id *cid*""" + try: + del self.observers[cid] + except KeyError: + pass + + class RadioButtons(AxesWidget): """ A GUI neutral radio button.