예제 #1
0
class LabelEditor(QWidget):
    """Widget for create label scheme."""
    def __init__(self, settings: ViewSettings):
        super().__init__()
        self.settings = settings
        self.color_list = []
        self.chosen = None
        self.prohibited_names = set(self.settings.label_color_dict.keys()
                                    )  # Prohibited name is added to reduce
        # probability of colormap cache collision

        self.color_picker = QColorDialog()
        self.color_picker.setWindowFlag(Qt.Widget)
        self.color_picker.setOptions(QColorDialog.DontUseNativeDialog
                                     | QColorDialog.NoButtons)
        self.add_color_btn = QPushButton("Add color")
        self.add_color_btn.clicked.connect(self.add_color)
        self.remove_color_btn = QPushButton("Remove last color")
        self.remove_color_btn.clicked.connect(self.remove_color)
        self.save_btn = QPushButton("Save")
        self.save_btn.clicked.connect(self.save)

        self.color_layout = QHBoxLayout()
        layout = QVBoxLayout()
        layout.addWidget(self.color_picker)
        btn_layout = QHBoxLayout()
        btn_layout.addWidget(self.add_color_btn)
        btn_layout.addWidget(self.remove_color_btn)
        btn_layout.addWidget(self.save_btn)
        layout.addLayout(btn_layout)
        layout.addLayout(self.color_layout)
        self.setLayout(layout)

    @Slot(list)
    def set_colors(self, colors: list):
        for _ in range(self.color_layout.count()):
            el = self.color_layout.takeAt(0)
            if el.widget():
                el.widget().deleteLater()
        for color in colors:
            self.color_layout.addWidget(ColorShow(color, self))

    def remove_color(self):
        if self.color_layout.count():
            el = self.color_layout.takeAt(self.color_layout.count() - 1)
            el.widget().deleteLater()

    def add_color(self):
        color = self.color_picker.currentColor()
        self.color_layout.addWidget(
            ColorShow([color.red(), color.green(),
                       color.blue()], self))

    def get_colors(self):
        count = self.color_layout.count()
        return [
            self.color_layout.itemAt(i).widget().color for i in range(count)
        ]

    def save(self):
        count = self.color_layout.count()
        if not count:
            return
        rand_name = custom_name_generate(self.prohibited_names,
                                         self.settings.label_color_dict)
        self.prohibited_names.add(rand_name)
        self.settings.label_color_dict[rand_name] = self.get_colors()

    def mousePressEvent(self, e: QMouseEvent):
        child = self.childAt(e.pos())
        if not isinstance(child, ColorShow):
            self.chosen = None
            return
        self.chosen = child

    def mouseMoveEvent(self, e: QMouseEvent):
        if self.chosen is None:
            return
        index = self.color_layout.indexOf(self.chosen)
        index2 = int(e.x() / self.width() * self.color_layout.count() + 0.5)
        if index2 != index:
            self.color_layout.insertWidget(index2, self.chosen)

    def mouseReleaseEvent(self, e: QMouseEvent):
        self.chosen = None
예제 #2
0
class ArrayEditorWidget(QWidget):

    dataChanged = Signal(list)

    def __init__(self,
                 parent,
                 data=None,
                 readonly=False,
                 bg_value=None,
                 bg_gradient='blue-red',
                 minvalue=None,
                 maxvalue=None,
                 digits=None):
        QWidget.__init__(self, parent)
        assert bg_gradient in gradient_map
        if data is not None and np.isscalar(data):
            readonly = True
        self.readonly = readonly

        # prepare internal views and models
        self.model_axes = AxesArrayModel(parent=self, readonly=readonly)
        self.view_axes = AxesView(parent=self, model=self.model_axes)

        self.model_hlabels = LabelsArrayModel(parent=self, readonly=readonly)
        self.view_hlabels = LabelsView(parent=self,
                                       model=self.model_hlabels,
                                       hpos=RIGHT,
                                       vpos=TOP)

        self.model_vlabels = LabelsArrayModel(parent=self, readonly=readonly)
        self.view_vlabels = LabelsView(parent=self,
                                       model=self.model_vlabels,
                                       hpos=LEFT,
                                       vpos=BOTTOM)

        self.model_data = DataArrayModel(parent=self,
                                         readonly=readonly,
                                         minvalue=minvalue,
                                         maxvalue=maxvalue)
        self.view_data = DataView(parent=self, model=self.model_data)

        # in case data is None
        self.data_adapter = None

        # Create vertical and horizontal scrollbars
        self.vscrollbar = ScrollBar(self, self.view_data.verticalScrollBar())
        self.hscrollbar = ScrollBar(self, self.view_data.horizontalScrollBar())

        # Synchronize resizing
        self.view_axes.horizontalHeader().sectionResized.connect(
            self.view_vlabels.updateSectionWidth)
        self.view_axes.verticalHeader().sectionResized.connect(
            self.view_hlabels.updateSectionHeight)
        self.view_hlabels.horizontalHeader().sectionResized.connect(
            self.view_data.updateSectionWidth)
        self.view_vlabels.verticalHeader().sectionResized.connect(
            self.view_data.updateSectionHeight)
        # Synchronize auto-resizing
        self.view_axes.horizontalHeader().sectionHandleDoubleClicked.connect(
            self.resize_axes_column_to_contents)
        self.view_hlabels.horizontalHeader(
        ).sectionHandleDoubleClicked.connect(
            self.resize_hlabels_column_to_contents)
        self.view_axes.verticalHeader().sectionHandleDoubleClicked.connect(
            self.resize_axes_row_to_contents)
        self.view_vlabels.verticalHeader().sectionHandleDoubleClicked.connect(
            self.resize_vlabels_row_to_contents)

        # synchronize specific methods
        self.view_axes.allSelected.connect(self.view_data.selectAll)
        self.view_data.signal_copy.connect(self.copy)
        self.view_data.signal_excel.connect(self.to_excel)
        self.view_data.signal_paste.connect(self.paste)
        self.view_data.signal_plot.connect(self.plot)

        # propagate changes (add new items in the QUndoStack attribute of MappingEditor)
        self.model_data.newChanges.connect(self.data_changed)

        # Synchronize scrolling
        # data <--> hlabels
        self.view_data.horizontalScrollBar().valueChanged.connect(
            self.view_hlabels.horizontalScrollBar().setValue)
        self.view_hlabels.horizontalScrollBar().valueChanged.connect(
            self.view_data.horizontalScrollBar().setValue)
        # data <--> vlabels
        self.view_data.verticalScrollBar().valueChanged.connect(
            self.view_vlabels.verticalScrollBar().setValue)
        self.view_vlabels.verticalScrollBar().valueChanged.connect(
            self.view_data.verticalScrollBar().setValue)

        # Synchronize selecting columns(rows) via hor.(vert.) header of x(y)labels view
        self.view_hlabels.horizontalHeader().sectionPressed.connect(
            self.view_data.selectColumn)
        self.view_hlabels.horizontalHeader().sectionEntered.connect(
            self.view_data.selectNewColumn)
        self.view_vlabels.verticalHeader().sectionPressed.connect(
            self.view_data.selectRow)
        self.view_vlabels.verticalHeader().sectionEntered.connect(
            self.view_data.selectNewRow)

        # following lines are required to keep usual selection color
        # when selecting rows/columns via headers of label views.
        # Otherwise, selected rows/columns appear in grey.
        self.view_data.setStyleSheet("""QTableView {
            selection-background-color: palette(highlight);
            selection-color: white;
        }""")

        # set external borders
        array_frame = QFrame(self)
        array_frame.setFrameStyle(QFrame.StyledPanel)
        # remove borders of internal tables
        self.view_axes.setFrameStyle(QFrame.NoFrame)
        self.view_hlabels.setFrameStyle(QFrame.NoFrame)
        self.view_vlabels.setFrameStyle(QFrame.NoFrame)
        self.view_data.setFrameStyle(QFrame.NoFrame)
        # Set layout of table views:
        # [ axes  ][hlabels]|V|
        # [vlabels][ data  ]|s|
        # |  H. scrollbar  |
        array_layout = QGridLayout()
        array_layout.addWidget(self.view_axes, 0, 0)
        array_layout.addWidget(self.view_hlabels, 0, 1)
        array_layout.addWidget(self.view_vlabels, 1, 0)
        self.view_data.setSizePolicy(QSizePolicy.Expanding,
                                     QSizePolicy.Expanding)
        array_layout.addWidget(self.view_data, 1, 1)
        array_layout.addWidget(self.vscrollbar, 0, 2, 2, 1)
        array_layout.addWidget(self.hscrollbar, 2, 0, 1, 2)
        array_layout.setSpacing(0)
        array_layout.setContentsMargins(0, 0, 0, 0)
        array_frame.setLayout(array_layout)

        # Set filters and buttons layout
        self.filters_layout = QHBoxLayout()
        self.btn_layout = QHBoxLayout()
        self.btn_layout.setAlignment(Qt.AlignLeft)

        label = QLabel("Digits")
        self.btn_layout.addWidget(label)
        spin = QSpinBox(self)
        spin.valueChanged.connect(self.digits_changed)
        self.digits_spinbox = spin
        self.btn_layout.addWidget(spin)
        self.digits = 0

        scientific = QCheckBox(_('Scientific'))
        scientific.stateChanged.connect(self.scientific_changed)
        self.scientific_checkbox = scientific
        self.btn_layout.addWidget(scientific)
        self.use_scientific = False

        gradient_chooser = QComboBox()
        gradient_chooser.setMaximumSize(120, 20)
        gradient_chooser.setIconSize(QSize(100, 20))

        pixmap = QPixmap(100, 15)
        pixmap.fill(Qt.white)
        gradient_chooser.addItem(QIcon(pixmap), " ")

        pixmap.fill(Qt.transparent)
        painter = QPainter(pixmap)
        for name, gradient in available_gradients[1:]:
            qgradient = gradient.as_qgradient()

            # * fill with white because gradient can be transparent and if we do not "start from whilte", it skews the
            #   colors.
            # * 1 and 13 instead of 0 and 15 to have a transparent border around/between the gradients
            painter.fillRect(0, 1, 100, 13, Qt.white)
            painter.fillRect(0, 1, 100, 13, qgradient)
            gradient_chooser.addItem(QIcon(pixmap), name, gradient)

        # without this, we can crash python :)
        del painter, pixmap
        # select default gradient
        # requires Qt5+
        # gradient_chooser.setCurrentText(bg_gradient)
        gradient_chooser.setCurrentIndex(
            gradient_chooser.findText(bg_gradient))
        gradient_chooser.currentIndexChanged.connect(self.gradient_changed)
        self.btn_layout.addWidget(gradient_chooser)
        self.gradient_chooser = gradient_chooser

        # Set widget layout
        layout = QVBoxLayout()
        layout.addLayout(self.filters_layout)
        layout.addWidget(array_frame)
        layout.addLayout(self.btn_layout)
        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)

        # set gradient
        self.model_data.set_bg_gradient(gradient_map[bg_gradient])

        # set data
        if data is not None:
            self.set_data(data, bg_value=bg_value, digits=digits)

        # See http://doc.qt.io/qt-4.8/qt-draganddrop-fridgemagnets-dragwidget-cpp.html for an example
        self.setAcceptDrops(True)

    def gradient_changed(self, index):
        gradient = self.gradient_chooser.itemData(index) if index > 0 else None
        self.model_data.set_bg_gradient(gradient)

    def data_changed(self, data_model_changes):
        changes = self.data_adapter.translate_changes(data_model_changes)
        self.dataChanged.emit(changes)

    def mousePressEvent(self, event):
        self.dragLabel = self.childAt(
            event.pos()) if event.button() == Qt.LeftButton else None
        self.dragStartPosition = event.pos()

    def mouseMoveEvent(self, event):
        from qtpy.QtCore import QMimeData, QByteArray
        from qtpy.QtGui import QPixmap, QDrag

        if not (event.button() != Qt.LeftButton
                and isinstance(self.dragLabel, QLabel)):
            return

        if (event.pos() - self.dragStartPosition
            ).manhattanLength() < QApplication.startDragDistance():
            return

        axis_index = self.filters_layout.indexOf(self.dragLabel) // 2

        # prepare hotSpot, mimeData and pixmap objects
        mimeData = QMimeData()
        mimeData.setText(self.dragLabel.text())
        mimeData.setData("application/x-axis-index",
                         QByteArray.number(axis_index))
        pixmap = QPixmap(self.dragLabel.size())
        self.dragLabel.render(pixmap)

        # prepare drag object
        drag = QDrag(self)
        drag.setMimeData(mimeData)
        drag.setPixmap(pixmap)
        drag.setHotSpot(event.pos() - self.dragStartPosition)

        drag.exec_(Qt.MoveAction | Qt.CopyAction, Qt.CopyAction)

    def dragEnterEvent(self, event):
        if event.mimeData().hasText():
            if self.filters_layout.geometry().contains(event.pos()):
                event.setDropAction(Qt.MoveAction)
                event.accept()
            else:
                event.acceptProposedAction()
        else:
            event.ignore()

    def dragMoveEvent(self, event):
        if event.mimeData().hasText() and self.filters_layout.geometry(
        ).contains(event.pos()):
            child = self.childAt(event.pos())
            if isinstance(child, QLabel) and child.text() != "Filters":
                event.setDropAction(Qt.MoveAction)
                event.accept()
            else:
                event.ignore()
        else:
            event.ignore()

    def dropEvent(self, event):
        if event.mimeData().hasText():
            if self.filters_layout.geometry().contains(event.pos()):
                old_index, success = event.mimeData().data(
                    "application/x-axis-index").toInt()
                new_index = self.filters_layout.indexOf(
                    self.childAt(event.pos())) // 2

                data, bg_value = self.data_adapter.data, self.data_adapter.bg_value
                data, bg_value = self.data_adapter.move_axis(
                    data, bg_value, old_index, new_index)
                self.set_data(data, bg_value)

                event.setDropAction(Qt.MoveAction)
                event.accept()
            else:
                event.acceptProposedAction()
        else:
            event.ignore()

    def _reset_minmax(self):
        self.model_data.reset_minmax()

    def _update_models(self, reset_model_data, reset_minmax):
        # axes names
        axes_names = self.data_adapter.get_axes_names(fold_last_axis=True)
        self.model_axes.set_data(axes_names)
        # horizontal labels
        hlabels = self.data_adapter.get_hlabels()
        self.model_hlabels.set_data(hlabels)
        # vertical labels
        vlabels = self.data_adapter.get_vlabels()
        self.model_vlabels.set_data(vlabels)
        # raw data
        # use flag reset=False to avoid calling reset() several times
        raw_data = self.data_adapter.get_raw_data()
        self.model_data.set_data(raw_data, reset=False)
        # bg value
        # use flag reset=False to avoid calling reset() several times
        bg_value = self.data_adapter.get_bg_value()
        self.model_data.set_bg_value(bg_value, reset=False)
        # reset min and max values if required
        if reset_minmax:
            self._reset_minmax()
        # reset the data model if required
        if reset_model_data:
            self.model_data.reset()

    def set_data(self, data, bg_value=None, digits=None):
        # get new adapter instance + set data
        self.data_adapter = get_adapter(data=data, bg_value=bg_value)
        # update filters
        self._update_filter()
        # update models
        # Note: model_data is reset by call of _update_digits_scientific below which call
        #       set_format which reset the data_model
        self._update_models(reset_model_data=False, reset_minmax=True)
        # update data format
        self._update_digits_scientific(digits=digits)
        # update gradient_chooser
        self.gradient_chooser.setEnabled(self.model_data.bgcolor_possible)
        # reset default size
        self._reset_default_size()
        # update dtype in view_data
        self.view_data.set_dtype(self.data_adapter.dtype)

    def _reset_default_size(self):
        self.view_axes.set_default_size()
        self.view_vlabels.set_default_size()
        self.view_hlabels.set_default_size()
        self.view_data.set_default_size()

    def _update_filter(self):
        filters_layout = self.filters_layout
        clear_layout(filters_layout)
        axes = self.data_adapter.get_axes_filtered_data()
        # size > 0 to avoid arrays with length 0 axes and len(axes) > 0 to avoid scalars (scalar.size == 1)
        if self.data_adapter.size > 0 and len(axes) > 0:
            filters_layout.addWidget(QLabel(_("Filters")))
            for axis in axes:
                filters_layout.addWidget(QLabel(axis.name))
                # FIXME: on very large axes, this is getting too slow. Ideally the combobox should use a model which
                # only fetch labels when they are needed to be displayed
                if len(axis) < 10000:
                    filters_layout.addWidget(self.create_filter_combo(axis))
                else:
                    filters_layout.addWidget(QLabel("too big to be filtered"))
            filters_layout.addStretch()

    def set_format(self, digits, scientific, reset=True):
        """Set format.

        Parameters
        ----------
        digits : int
            Number of digits to display.
        scientific : boolean
            Whether or not to display values in scientific format.
        reset: boolean, optional
            Whether or not to reset the data model. Defaults to True.
        """
        type = self.data_adapter.dtype.type
        if type in (np.str, np.str_, np.bool_, np.bool, np.object_):
            fmt = '%s'
        else:
            format_letter = 'e' if scientific else 'f'
            fmt = '%%.%d%s' % (digits, format_letter)
        self.model_data.set_format(fmt, reset)

    # two cases:
    # * set_data should update both scientific and ndigits
    # * toggling scientific checkbox should update only ndigits
    def _update_digits_scientific(self, scientific=None, digits=None):
        dtype = self.data_adapter.dtype
        if dtype.type in (np.str, np.str_, np.bool_, np.bool, np.object_):
            scientific = False
            ndecimals = 0
        else:
            data = self.data_adapter.get_sample()

            # max_digits = self.get_max_digits()
            # default width can fit 8 chars
            # FIXME: use max_digits?
            avail_digits = 8
            frac_zeros, int_digits, has_negative = self.format_helper(data)

            # choose whether or not to use scientific notation
            # ================================================
            if scientific is None:
                # use scientific format if there are more integer digits than we can display or if we can display more
                # information that way (scientific format "uses" 4 digits, so we have a net win if we have >= 4 zeros --
                # *including the integer one*)
                # TODO: only do so if we would actually display more information
                # 0.00001 can be displayed with 8 chars
                # 1e-05
                # would
                scientific = int_digits > avail_digits or frac_zeros >= 4

            # determine best number of decimals to display
            # ============================================
            # TODO: ndecimals vs self.digits => rename self.digits to either frac_digits or ndecimals
            if digits is not None:
                ndecimals = digits
            else:
                data_frac_digits = self._data_digits(data)
                if scientific:
                    int_digits = 2 if has_negative else 1
                    exp_digits = 4
                else:
                    exp_digits = 0
                # - 1 for the dot
                ndecimals = avail_digits - 1 - int_digits - exp_digits

                if ndecimals < 0:
                    ndecimals = 0

                if data_frac_digits < ndecimals:
                    ndecimals = data_frac_digits

        self.digits = ndecimals
        self.use_scientific = scientific

        # avoid triggering digits_changed which would cause a useless redraw
        self.digits_spinbox.blockSignals(True)
        self.digits_spinbox.setValue(ndecimals)
        self.digits_spinbox.setEnabled(is_number(dtype))
        self.digits_spinbox.blockSignals(False)

        # avoid triggering scientific_changed which would call this function a second time
        self.scientific_checkbox.blockSignals(True)
        self.scientific_checkbox.setChecked(scientific)
        self.scientific_checkbox.setEnabled(is_number(dtype))
        self.scientific_checkbox.blockSignals(False)

        # 1) setting the format explicitly instead of relying on digits_spinbox.digits_changed to set it because
        #    digits_changed is only triggered when digits actually changed, not when passing from
        #    scientific -> non scientific or number -> object
        # 2) data model is reset in set_format by default
        self.set_format(ndecimals, scientific)

    def format_helper(self, data):
        if not data.size:
            return 0, 0, False
        data = np.where(np.isfinite(data), data, 0)
        vmin, vmax = np.min(data), np.max(data)
        absmax = max(abs(vmin), abs(vmax))
        logabsmax = math.log10(absmax) if absmax else 0
        # minimum number of zeros before meaningful fractional part
        frac_zeros = math.ceil(-logabsmax) - 1 if logabsmax < 0 else 0
        int_digits = max(ndigits(vmin), ndigits(vmax))
        return frac_zeros, int_digits, vmin < 0

    def get_max_digits(self,
                       need_sign=False,
                       need_dot=False,
                       scientific=False):
        font = get_font("arreditor")  # QApplication.font()
        col_width = 60
        margin_width = 6  # a wild guess
        avail_width = col_width - margin_width
        metrics = QFontMetrics(font)

        def str_width(c):
            return metrics.size(Qt.TextSingleLine, c).width()

        digit_width = max(str_width(str(i)) for i in range(10))
        dot_width = str_width('.')
        sign_width = max(str_width('+'), str_width('-'))
        if need_sign:
            avail_width -= sign_width
        if need_dot:
            avail_width -= dot_width
        if scientific:
            avail_width -= str_width('e') + sign_width + 2 * digit_width
        return avail_width // digit_width

    def _data_digits(self, data, maxdigits=6):
        if not data.size:
            return 0
        threshold = 10**-(maxdigits + 1)
        for ndigits in range(maxdigits):
            maxdiff = np.max(np.abs(data - np.round(data, ndigits)))
            if maxdiff < threshold:
                return ndigits
        return maxdigits

    def autofit_columns(self):
        self.view_axes.autofit_columns()
        for column in range(self.model_axes.columnCount()):
            self.resize_axes_column_to_contents(column)
        self.view_hlabels.autofit_columns()
        for column in range(self.model_hlabels.columnCount()):
            self.resize_hlabels_column_to_contents(column)

    def resize_axes_column_to_contents(self, column):
        # must be connected to view_axes.horizontalHeader().sectionHandleDoubleClicked signal
        width = max(self.view_axes.horizontalHeader().sectionSize(column),
                    self.view_vlabels.sizeHintForColumn(column))
        # no need to call resizeSection on view_vlabels (see synchronization lines in init)
        self.view_axes.horizontalHeader().resizeSection(column, width)

    def resize_hlabels_column_to_contents(self, column):
        # must be connected to view_labels.horizontalHeader().sectionHandleDoubleClicked signal
        width = max(self.view_hlabels.horizontalHeader().sectionSize(column),
                    self.view_data.sizeHintForColumn(column))
        # no need to call resizeSection on view_data (see synchronization lines in init)
        self.view_hlabels.horizontalHeader().resizeSection(column, width)

    def resize_axes_row_to_contents(self, row):
        # must be connected to view_axes.verticalHeader().sectionHandleDoubleClicked
        height = max(self.view_axes.verticalHeader().sectionSize(row),
                     self.view_hlabels.sizeHintForRow(row))
        # no need to call resizeSection on view_hlabels (see synchronization lines in init)
        self.view_axes.verticalHeader().resizeSection(row, height)

    def resize_vlabels_row_to_contents(self, row):
        # must be connected to view_labels.verticalHeader().sectionHandleDoubleClicked
        height = max(self.view_vlabels.verticalHeader().sectionSize(row),
                     self.view_data.sizeHintForRow(row))
        # no need to call resizeSection on view_data (see synchronization lines in init)
        self.view_vlabels.verticalHeader().resizeSection(row, height)

    def scientific_changed(self, value):
        self._update_digits_scientific(scientific=value)

    def digits_changed(self, value):
        self.digits = value
        self.set_format(value, self.use_scientific)

    def change_filter(self, axis, indices):
        self.data_adapter.update_filter(axis, indices)
        self._update_models(reset_model_data=True, reset_minmax=False)

    def create_filter_combo(self, axis):
        def filter_changed(checked_items):
            self.change_filter(axis, checked_items)

        combo = FilterComboBox(self)
        combo.addItems([str(l) for l in axis.labels])
        combo.checkedItemsChanged.connect(filter_changed)
        return combo

    def _selection_data(self, headers=True, none_selects_all=True):
        """
        Returns selected labels as lists and raw data as Numpy ndarray
        if headers=True or only the raw data otherwise

        Parameters
        ----------
        headers : bool, optional
            Labels are also returned if True.
        none_selects_all : bool, optional
            If True (default) and selection is empty, returns all data.

        Returns
        -------
        raw_data: numpy.ndarray
        axes_names: list
        vlabels: nested list
        hlabels: list
        """
        bounds = self.view_data._selection_bounds(
            none_selects_all=none_selects_all)
        if bounds is None:
            return None
        row_min, row_max, col_min, col_max = bounds
        raw_data = self.model_data.get_values(row_min, col_min, row_max,
                                              col_max)
        if headers:
            if not self.data_adapter.ndim:
                return raw_data, None, None, None
            axes_names = self.model_axes.get_values()
            hlabels = [
                label[0]
                for label in self.model_hlabels.get_values(top=col_min,
                                                           bottom=col_max)
            ]
            vlabels = self.model_vlabels.get_values(
                left=row_min,
                right=row_max) if self.data_adapter.ndim > 1 else []
            return raw_data, axes_names, vlabels, hlabels
        else:
            return raw_data

    def copy(self):
        """Copy selection as text to clipboard"""
        raw_data, axes_names, vlabels, hlabels = self._selection_data()
        data = self.data_adapter.selection_to_chain(raw_data, axes_names,
                                                    vlabels, hlabels)
        if data is None:
            return

        # np.savetxt make things more complicated, especially on py3
        # XXX: why don't we use repr for everything?
        def vrepr(v):
            if isinstance(v, float):
                return repr(v)
            else:
                return str(v)

        text = '\n'.join('\t'.join(vrepr(v) for v in line) for line in data)
        clipboard = QApplication.clipboard()
        clipboard.setText(text)

    def to_excel(self):
        """Export selection in Excel"""
        raw_data, axes_names, vlabels, hlabels = self._selection_data()
        try:
            self.data_adapter.to_excel(raw_data, axes_names, vlabels, hlabels)
        except ImportError:
            QMessageBox.critical(
                self, "Error",
                "to_excel() is not available because xlwings is not installed")

    def paste(self):
        bounds = self.view_data._selection_bounds()
        if bounds is None:
            return
        row_min, row_max, col_min, col_max = bounds
        clipboard = QApplication.clipboard()
        text = str(clipboard.text())
        list_data = [line.split('\t') for line in text.splitlines()]
        try:
            # take the first cell which contains '\'
            pos_last = next(i for i, v in enumerate(list_data[0]) if '\\' in v)
        except StopIteration:
            # if there isn't any, assume 1d array
            pos_last = 0
        if pos_last or '\\' in list_data[0][0]:
            # ndim > 1
            list_data = [line[pos_last + 1:] for line in list_data[1:]]
        elif len(list_data) == 2 and list_data[1][0] == '':
            # ndim == 1
            list_data = [list_data[1][1:]]
        new_data = np.array(list_data)
        if new_data.shape[0] > 1:
            row_max = row_min + new_data.shape[0]
        if new_data.shape[1] > 1:
            col_max = col_min + new_data.shape[1]

        result = self.model_data.set_values(row_min, col_min, row_max, col_max,
                                            new_data)

        if result is None:
            return

        # TODO: when pasting near bottom/right boundaries and size of
        # new_data exceeds destination size, we should either have an error
        # or clip new_data
        self.view_data.selectionModel().select(
            QItemSelection(*result), QItemSelectionModel.ClearAndSelect)

    def plot(self):
        raw_data, axes_names, vlabels, hlabels = self._selection_data()
        try:
            from larray_editor.utils import show_figure
            figure = self.data_adapter.plot(raw_data, axes_names, vlabels,
                                            hlabels)
            # Display figure
            show_figure(self, figure)
        except ImportError:
            QMessageBox.critical(
                self, "Error",
                "plot() is not available because matplotlib is not installed")