diff --git a/doc/users/whats_new/2016-04_qt_config-AL.rst b/doc/users/whats_new/2016-04_qt_config-AL.rst new file mode 100644 index 000000000000..434ec43c7cda --- /dev/null +++ b/doc/users/whats_new/2016-04_qt_config-AL.rst @@ -0,0 +1,14 @@ +Improvements for the Qt figure options editor +--------------------------------------------- + +Various usability improvements were implemented for the Qt figure options +editor, among which: +- Line style entries are now sorted without duplicates. +- The colormap and normalization limits can now be set for images. +- Line edits for floating values now display only as many digits as necessary + to avoid precision loss. An important bug was also fixed regarding input + validation using Qt5 and a locale where the decimal separator is ",". +- The axes selector now uses shorter, more user-friendly names for axes, and + does not crash if there are no axes. +- Line and image entries using the default labels ("_lineX", "_imageX") are now + sorted numerically even when there are more than 10 entries. diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 1e50188d14f0..05c4e43265d9 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1732,6 +1732,8 @@ def add_image(self, image): Returns the image. """ self._set_artist_props(image) + if not image.get_label(): + image.set_label('_image%d' % len(self.images)) self.images.append(image) image._remove_method = lambda h: self.images.remove(h) self.stale = True diff --git a/lib/matplotlib/backends/qt_editor/figureoptions.py b/lib/matplotlib/backends/qt_editor/figureoptions.py index 1b220173c5f6..038073edd6a1 100644 --- a/lib/matplotlib/backends/qt_editor/figureoptions.py +++ b/lib/matplotlib/backends/qt_editor/figureoptions.py @@ -13,23 +13,24 @@ import six import os.path as osp +import re +import matplotlib +from matplotlib import cm, markers, colors as mcolors import matplotlib.backends.qt_editor.formlayout as formlayout from matplotlib.backends.qt_compat import QtGui -from matplotlib import markers -from matplotlib.colors import colorConverter, rgb2hex def get_icon(name): - import matplotlib basedir = osp.join(matplotlib.rcParams['datapath'], 'images') return QtGui.QIcon(osp.join(basedir, name)) + LINESTYLES = {'-': 'Solid', '--': 'Dashed', '-.': 'DashDot', ':': 'Dotted', - 'none': 'None', + 'None': 'None', } DRAWSTYLES = {'default': 'Default', @@ -43,8 +44,6 @@ def figure_edit(axes, parent=None): """Edit matplotlib figure options""" sep = (None, None) # separator - has_curve = len(axes.get_lines()) > 0 - # Get / General xmin, xmax = axes.get_xlim() ymin, ymax = axes.get_ylim() @@ -69,57 +68,115 @@ def figure_edit(axes, parent=None): xunits = axes.xaxis.get_units() yunits = axes.yaxis.get_units() - if has_curve: - # Get / Curves - linedict = {} - for line in axes.get_lines(): - label = line.get_label() - if label == '_nolegend_': - continue - linedict[label] = line - curves = [] - linestyles = list(six.iteritems(LINESTYLES)) - drawstyles = list(six.iteritems(DRAWSTYLES)) - markers = list(six.iteritems(MARKERS)) - curvelabels = sorted(linedict.keys()) - for label in curvelabels: - line = linedict[label] - color = rgb2hex(colorConverter.to_rgb(line.get_color())) - ec = rgb2hex(colorConverter.to_rgb(line.get_markeredgecolor())) - fc = rgb2hex(colorConverter.to_rgb(line.get_markerfacecolor())) - curvedata = [('Label', label), - sep, - (None, 'Line'), - ('Line Style', [line.get_linestyle()] + linestyles), - ('Draw Style', [line.get_drawstyle()] + drawstyles), - ('Width', line.get_linewidth()), - ('Color', color), - sep, - (None, 'Marker'), - ('Style', [line.get_marker()] + markers), - ('Size', line.get_markersize()), - ('Facecolor', fc), - ('Edgecolor', ec), - ] - curves.append([curvedata, label, ""]) - - # make sure that there is at least one displayed curve - has_curve = bool(curves) + # Sorting for default labels (_lineXXX, _imageXXX). + def cmp_key(label): + match = re.match(r"(_line|_image)(\d+)", label) + if match: + return match.group(1), int(match.group(2)) + else: + return label, 0 + + # Get / Curves + linedict = {} + for line in axes.get_lines(): + label = line.get_label() + if label == '_nolegend_': + continue + linedict[label] = line + curves = [] + + def prepare_data(d, init): + """Prepare entry for FormLayout. + + `d` is a mapping of shorthands to style names (a single style may + have multiple shorthands, in particular the shorthands `None`, + `"None"`, `"none"` and `""` are synonyms); `init` is one shorthand + of the initial style. + + This function returns an list suitable for initializing a + FormLayout combobox, namely `[initial_name, (shorthand, + style_name), (shorthand, style_name), ...]`. + """ + # Drop duplicate shorthands from dict (by overwriting them during + # the dict comprehension). + name2short = {name: short for short, name in d.items()} + # Convert back to {shorthand: name}. + short2name = {short: name for name, short in name2short.items()} + # Find the kept shorthand for the style specified by init. + canonical_init = name2short[d[init]] + # Sort by representation and prepend the initial value. + return ([canonical_init] + + sorted(short2name.items(), + key=lambda short_and_name: short_and_name[1])) + + curvelabels = sorted(linedict, key=cmp_key) + for label in curvelabels: + line = linedict[label] + color = mcolors.to_hex( + mcolors.to_rgba(line.get_color(), line.get_alpha()), + keep_alpha=True) + ec = mcolors.to_hex(line.get_markeredgecolor(), keep_alpha=True) + fc = mcolors.to_hex(line.get_markerfacecolor(), keep_alpha=True) + curvedata = [ + ('Label', label), + sep, + (None, 'Line'), + ('Line style', prepare_data(LINESTYLES, line.get_linestyle())), + ('Draw style', prepare_data(DRAWSTYLES, line.get_drawstyle())), + ('Width', line.get_linewidth()), + ('Color (RGBA)', color), + sep, + (None, 'Marker'), + ('Style', prepare_data(MARKERS, line.get_marker())), + ('Size', line.get_markersize()), + ('Face color (RGBA)', fc), + ('Edge color (RGBA)', ec)] + curves.append([curvedata, label, ""]) + # Is there a curve displayed? + has_curve = bool(curves) + + # Get / Images + imagedict = {} + for image in axes.get_images(): + label = image.get_label() + if label == '_nolegend_': + continue + imagedict[label] = image + imagelabels = sorted(imagedict, key=cmp_key) + images = [] + cmaps = [(cmap, name) for name, cmap in sorted(cm.cmap_d.items())] + for label in imagelabels: + image = imagedict[label] + cmap = image.get_cmap() + if cmap not in cm.cmap_d.values(): + cmaps = [(cmap, cmap.name)] + cmaps + low, high = image.get_clim() + imagedata = [ + ('Label', label), + ('Colormap', [cmap.name] + cmaps), + ('Min. value', low), + ('Max. value', high)] + images.append([imagedata, label, ""]) + # Is there an image displayed? + has_image = bool(images) datalist = [(general, "Axes", "")] - if has_curve: + if curves: datalist.append((curves, "Curves", "")) + if images: + datalist.append((images, "Images", "")) def apply_callback(data): """This function will be called to apply changes""" - if has_curve: - general, curves = data - else: - general, = data + general = data.pop(0) + curves = data.pop(0) if has_curve else [] + images = data.pop(0) if has_image else [] + if data: + raise ValueError("Unexpected field") # Set / General - title, xmin, xmax, xlabel, xscale, ymin, ymax, ylabel, yscale, \ - generate_legend = general + (title, xmin, xmax, xlabel, xscale, ymin, ymax, ylabel, yscale, + generate_legend) = general if axes.get_xscale() != xscale: axes.set_xscale(xscale) @@ -140,26 +197,33 @@ def apply_callback(data): axes.xaxis._update_axisinfo() axes.yaxis._update_axisinfo() - if has_curve: - # Set / Curves - for index, curve in enumerate(curves): - line = linedict[curvelabels[index]] - label, linestyle, drawstyle, linewidth, color, \ - marker, markersize, markerfacecolor, markeredgecolor \ - = curve - line.set_label(label) - line.set_linestyle(linestyle) - line.set_drawstyle(drawstyle) - line.set_linewidth(linewidth) - line.set_color(color) - if marker is not 'none': - line.set_marker(marker) - line.set_markersize(markersize) - line.set_markerfacecolor(markerfacecolor) - line.set_markeredgecolor(markeredgecolor) + # Set / Curves + for index, curve in enumerate(curves): + line = linedict[curvelabels[index]] + (label, linestyle, drawstyle, linewidth, color, marker, markersize, + markerfacecolor, markeredgecolor) = curve + line.set_label(label) + line.set_linestyle(linestyle) + line.set_drawstyle(drawstyle) + line.set_linewidth(linewidth) + rgba = mcolors.to_rgba(color) + line.set_color(rgba[:3]) + line.set_alpha(rgba[-1]) + if marker is not 'none': + line.set_marker(marker) + line.set_markersize(markersize) + line.set_markerfacecolor(markerfacecolor) + line.set_markeredgecolor(markeredgecolor) + + # Set / Images + for index, image_settings in enumerate(images): + image = imagedict[imagelabels[index]] + label, cmap, low, high = image_settings + image.set_label(label) + image.set_cmap(cm.get_cmap(cmap)) + image.set_clim(*sorted([low, high])) # re-generate legend, if checkbox is checked - if generate_legend: draggable = None ncol = 1 diff --git a/lib/matplotlib/backends/qt_editor/formlayout.py b/lib/matplotlib/backends/qt_editor/formlayout.py index a786a6105342..00a1a03a36ee 100644 --- a/lib/matplotlib/backends/qt_editor/formlayout.py +++ b/lib/matplotlib/backends/qt_editor/formlayout.py @@ -32,43 +32,33 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from __future__ import (absolute_import, division, print_function, - unicode_literals) - -import six -from six.moves import xrange # History: # 1.0.10: added float validator (disable "Ok" and "Apply" button when not valid) # 1.0.7: added support for "Apply" button # 1.0.6: code cleaning +from __future__ import (absolute_import, division, print_function, + unicode_literals) + __version__ = '1.0.10' __license__ = __doc__ DEBUG = False -import sys -STDERR = sys.stderr +import copy +import datetime +import warnings -from matplotlib.colors import is_color_like -from matplotlib.colors import rgb2hex -from matplotlib.colors import colorConverter +import six +from matplotlib import colors as mcolors from matplotlib.backends.qt_compat import QtGui, QtWidgets, QtCore -if not hasattr(QtWidgets, 'QFormLayout'): - raise ImportError("Warning: formlayout requires PyQt4 >v4.3 or PySide") -import datetime BLACKLIST = set(["title", "label"]) -def col2hex(color): - """Convert matplotlib color to hex before passing to Qt""" - return rgb2hex(colorConverter.to_rgb(color)) - - class ColorButton(QtWidgets.QPushButton): """ Color choosing push button @@ -83,7 +73,9 @@ def __init__(self, parent=None): self._color = QtGui.QColor() def choose_color(self): - color = QtWidgets.QColorDialog.getColor(self._color, self.parentWidget(), '') + color = QtWidgets.QColorDialog.getColor( + self._color, self.parentWidget(), "", + QtWidgets.QColorDialog.ShowAlphaChannel) if color.isValid(): self.set_color(color) @@ -101,21 +93,17 @@ def set_color(self, color): color = QtCore.Property(QtGui.QColor, get_color, set_color) -def col2hex(color): - """Convert matplotlib color to hex before passing to Qt""" - return rgb2hex(colorConverter.to_rgb(color)) def to_qcolor(color): """Create a QColor from a matplotlib color""" qcolor = QtGui.QColor() - color = str(color) try: - color = col2hex(color) + rgba = mcolors.to_rgba(color) except ValueError: - #print('WARNING: ignoring invalid color %r' % color) + warnings.warn('Ignoring invalid color %r' % color) return qcolor # return invalid QColor - qcolor.setNamedColor(color) # set using hex color - return qcolor # return valid QColor + qcolor.setRgbF(*rgba) + return qcolor class ColorLayout(QtWidgets.QHBoxLayout): @@ -123,7 +111,8 @@ class ColorLayout(QtWidgets.QHBoxLayout): def __init__(self, color, parent=None): QtWidgets.QHBoxLayout.__init__(self) assert isinstance(color, QtGui.QColor) - self.lineedit = QtWidgets.QLineEdit(color.name(), parent) + self.lineedit = QtWidgets.QLineEdit( + mcolors.to_hex(color.getRgbF(), keep_alpha=True), parent) self.lineedit.editingFinished.connect(self.update_color) self.addWidget(self.lineedit) self.colorbtn = ColorButton(parent) @@ -137,7 +126,7 @@ def update_color(self): self.colorbtn.color = qcolor # defaults to black if not qcolor.isValid() def update_text(self, color): - self.lineedit.setText(color.name()) + self.lineedit.setText(mcolors.to_hex(color.getRgbF(), keep_alpha=True)) def text(self): return self.lineedit.text() @@ -146,7 +135,7 @@ def text(self): def font_is_installed(font): """Check if font is installed""" return [fam for fam in QtGui.QFontDatabase().families() - if six.text_type(fam) == font] + if six.text_type(fam) == font] def tuple_to_qfont(tup): @@ -154,11 +143,11 @@ def tuple_to_qfont(tup): Create a QFont from tuple: (family [string], size [int], italic [bool], bold [bool]) """ - if not isinstance(tup, tuple) or len(tup) != 4 \ - or not font_is_installed(tup[0]) \ - or not isinstance(tup[1], int) \ - or not isinstance(tup[2], bool) \ - or not isinstance(tup[3], bool): + if not (isinstance(tup, tuple) and len(tup) == 4 + and font_is_installed(tup[0]) + and isinstance(tup[1], int) + and isinstance(tup[2], bool) + and isinstance(tup[3], bool)): return None font = QtGui.QFont() family, size, italic, bold = tup @@ -189,7 +178,7 @@ def __init__(self, value, parent=None): # Font size self.size = QtWidgets.QComboBox(parent) self.size.setEditable(True) - sizelist = list(xrange(6, 12)) + list(xrange(12, 30, 2)) + [36, 48, 72] + sizelist = list(range(6, 12)) + list(range(12, 30, 2)) + [36, 48, 72] size = font.pointSize() if size not in sizelist: sizelist.append(size) @@ -227,8 +216,7 @@ class FormWidget(QtWidgets.QWidget): update_buttons = QtCore.Signal() def __init__(self, data, comment="", parent=None): QtWidgets.QWidget.__init__(self, parent) - from copy import deepcopy - self.data = deepcopy(data) + self.data = copy.deepcopy(data) self.widgets = [] self.formlayout = QtWidgets.QFormLayout(self) if comment: @@ -264,7 +252,8 @@ def setup(self): continue elif tuple_to_qfont(value) is not None: field = FontLayout(value, self) - elif label.lower() not in BLACKLIST and is_color_like(value): + elif (label.lower() not in BLACKLIST + and mcolors.is_color_like(value)): field = ColorLayout(to_qcolor(value), self) elif isinstance(value, six.string_types): field = QtWidgets.QLineEdit(value, self) @@ -284,8 +273,9 @@ def setup(self): elif selindex in keys: selindex = keys.index(selindex) elif not isinstance(selindex, int): - print("Warning: '%s' index is invalid (label: " - "%s, value: %s)" % (selindex, label, value), file=STDERR) + warnings.warn( + "index '%s' is invalid (label: %s, value: %s)" % + (selindex, label, value)) selindex = 0 field.setCurrentIndex(selindex) elif isinstance(value, bool): @@ -326,7 +316,8 @@ def get(self): continue elif tuple_to_qfont(value) is not None: value = field.get_font() - elif isinstance(value, six.string_types) or is_color_like(value): + elif (isinstance(value, six.string_types) + or mcolors.is_color_like(value)): value = six.text_type(field.text()) elif isinstance(value, (list, tuple)): index = int(field.currentIndex()) @@ -431,8 +422,8 @@ def __init__(self, data, title="", comment="", self.formwidget.setup() # Button box - self.bbox = bbox = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok - | QtWidgets.QDialogButtonBox.Cancel) + self.bbox = bbox = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) self.formwidget.update_buttons.connect(self.update_buttons) if self.apply_callback is not None: apply_btn = bbox.addButton(QtWidgets.QDialogButtonBox.Apply) @@ -457,7 +448,8 @@ def update_buttons(self): for field in self.float_fields: if not is_edit_valid(field): valid = False - for btn_type in (QtWidgets.QDialogButtonBox.Ok, QtWidgets.QDialogButtonBox.Apply): + for btn_type in (QtWidgets.QDialogButtonBox.Ok, + QtWidgets.QDialogButtonBox.Apply): btn = self.bbox.button(btn_type) if btn is not None: btn.setEnabled(valid) diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index 0db136682d9f..eca3fa1fe49b 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -1505,6 +1505,13 @@ def issubclass_safe(x, klass): def safe_masked_invalid(x, copy=False): x = np.array(x, subok=True, copy=copy) + if not x.dtype.isnative: + # Note that the argument to `byteswap` is 'inplace', + # thus if we have already made a copy, do the byteswap in + # place, else make a copy with the byte order swapped. + # Be explicit that we are swapping the byte order of the dtype + x = x.byteswap(copy).newbyteorder('S') + try: xm = np.ma.masked_invalid(x, copy=False) xm.shrink_mask() diff --git a/lib/matplotlib/tests/baseline_images/test_image/imshow_endianess.png b/lib/matplotlib/tests/baseline_images/test_image/imshow_endianess.png new file mode 100644 index 000000000000..148acf125174 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_image/imshow_endianess.png differ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 1b0022d63e3b..f4e45ae7fd36 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -4553,6 +4553,7 @@ def test_large_offset(): fig.canvas.draw() +@cleanup def test_bar_color_cycle(): ccov = mcolors.colorConverter.to_rgb fig, ax = plt.subplots() diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index 08d55155d5fa..f2d5fb019010 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -715,6 +715,21 @@ def test_mask_image(): ax2.imshow(A, interpolation='nearest') -if __name__=='__main__': - import nose - nose.runmodule(argv=['-s','--with-doctest'], exit=False) +@image_comparison(baseline_images=['imshow_endianess'], + remove_text=True, extensions=['png']) +def test_imshow_endianess(): + x = np.arange(10) + X, Y = np.meshgrid(x, x) + Z = ((X-5)**2 + (Y-5)**2)**0.5 + + fig, (ax1, ax2) = plt.subplots(1, 2) + + kwargs = dict(origin="lower", interpolation='nearest', + cmap='viridis') + + ax1.imshow(Z.astype('f8'), **kwargs) + + +if __name__ == '__main__': + nose.runmodule(argv=['-s', '--with-doctest'], exit=False)