# -*- coding: utf-8 -*- # # Copyright © 2009-2012 Pierre Raybaut # Licensed under the terms of the MIT License # (see spyderlib/__init__.py for details) """ NumPy Array Editor Dialog based on Qt """ # pylint: disable=C0103 # pylint: disable=R0903 # pylint: disable=R0911 # pylint: disable=R0201 from __future__ import print_function from spyderlib.qt.QtGui import (QHBoxLayout, QColor, QTableView, QItemDelegate, QLineEdit, QCheckBox, QGridLayout, QCursor, QDoubleValidator, QDialog, QDialogButtonBox, QMessageBox, QPushButton, QInputDialog, QMenu, QApplication, QKeySequence, QLabel, QComboBox, QSpinBox, QStackedWidget, QWidget, QVBoxLayout) from spyderlib.qt.QtCore import (Qt, QModelIndex, QAbstractTableModel, SIGNAL, SLOT) from spyderlib.qt.compat import to_qvariant, from_qvariant import numpy as np # Local imports from spyderlib.baseconfig import _ from spyderlib.guiconfig import get_font, new_shortcut from spyderlib.utils.qthelpers import (add_actions, create_action, keybinding, qapplication, get_icon) from spyderlib.py3compat import io, to_text_string, is_text_string # Note: string and unicode data types will be formatted with '%s' (see below) SUPPORTED_FORMATS = { 'single': '%.3f', 'double': '%.3f', 'float_': '%.3f', 'longfloat': '%.3f', 'float32': '%.3f', 'float64': '%.3f', 'float96': '%.3f', 'float128': '%.3f', 'csingle': '%r', 'complex_': '%r', 'clongfloat': '%r', 'complex64': '%r', 'complex128': '%r', 'complex192': '%r', 'complex256': '%r', 'byte': '%d', 'short': '%d', 'intc': '%d', 'int_': '%d', 'longlong': '%d', 'intp': '%d', 'int8': '%d', 'int16': '%d', 'int32': '%d', 'int64': '%d', 'ubyte': '%d', 'ushort': '%d', 'uintc': '%d', 'uint': '%d', 'ulonglong': '%d', 'uintp': '%d', 'uint8': '%d', 'uint16': '%d', 'uint32': '%d', 'uint64': '%d', 'bool_': '%r', 'bool8': '%r', 'bool': '%r', } LARGE_SIZE = 5e5 LARGE_NROWS = 1e5 LARGE_COLS = 60 def is_float(dtype): """Return True if datatype dtype is a float kind""" return ('float' in dtype.name) or dtype.name in ['single', 'double'] def is_number(dtype): """Return True is datatype dtype is a number kind""" return is_float(dtype) or ('int' in dtype.name) or ('long' in dtype.name) \ or ('short' in dtype.name) def get_idx_rect(index_list): """Extract the boundaries from a list of indexes""" rows, cols = list(zip(*[(i.row(), i.column()) for i in index_list])) return ( min(rows), max(rows), min(cols), max(cols) ) class ArrayModel(QAbstractTableModel): """Array Editor Table Model""" ROWS_TO_LOAD = 500 COLS_TO_LOAD = 40 def __init__(self, data, format="%.3f", xlabels=None, ylabels=None, readonly=False, parent=None): QAbstractTableModel.__init__(self) self.dialog = parent self.changes = {} self.xlabels = xlabels self.ylabels = ylabels self.readonly = readonly self.test_array = np.array([0], dtype=data.dtype) # for complex numbers, shading will be based on absolute value # but for all other types it will be the real part if data.dtype in (np.complex64, np.complex128): self.color_func = np.abs else: self.color_func = np.real # Backgroundcolor settings huerange = [.66, .99] # Hue self.sat = .7 # Saturation self.val = 1. # Value self.alp = .6 # Alpha-channel self._data = data self._format = format self.total_rows = self._data.shape[0] self.total_cols = self._data.shape[1] size = self.total_rows * self.total_cols try: self.vmin = self.color_func(data).min() self.vmax = self.color_func(data).max() if self.vmax == self.vmin: self.vmin -= 1 self.hue0 = huerange[0] self.dhue = huerange[1]-huerange[0] self.bgcolor_enabled = True except TypeError: self.vmin = None self.vmax = None self.hue0 = None self.dhue = None self.bgcolor_enabled = False # Use paging when the total size, number of rows or number of # columns is too large if size > LARGE_SIZE: self.rows_loaded = self.ROWS_TO_LOAD self.cols_loaded = self.COLS_TO_LOAD else: if self.total_rows > LARGE_NROWS: self.rows_loaded = self.ROWS_TO_LOAD else: self.rows_loaded = self.total_rows if self.total_cols > LARGE_COLS: self.cols_loaded = self.COLS_TO_LOAD else: self.cols_loaded = self.total_cols def get_format(self): """Return current format""" # Avoid accessing the private attribute _format from outside return self._format def get_data(self): """Return data""" return self._data def set_format(self, format): """Change display format""" self._format = format self.reset() def columnCount(self, qindex=QModelIndex()): """Array column number""" if self.total_cols <= self.cols_loaded: return self.total_cols else: return self.cols_loaded def rowCount(self, qindex=QModelIndex()): """Array row number""" if self.total_rows <= self.rows_loaded: return self.total_rows else: return self.rows_loaded def can_fetch_more(self, rows=False, columns=False): if rows: if self.total_rows > self.rows_loaded: return True else: return False if columns: if self.total_cols > self.cols_loaded: return True else: return False def fetch_more(self, rows=False, columns=False): if self.can_fetch_more(rows=rows): reminder = self.total_rows - self.rows_loaded items_to_fetch = min(reminder, self.ROWS_TO_LOAD) self.beginInsertRows(QModelIndex(), self.rows_loaded, self.rows_loaded + items_to_fetch - 1) self.rows_loaded += items_to_fetch self.endInsertRows() if self.can_fetch_more(columns=columns): reminder = self.total_cols - self.cols_loaded items_to_fetch = min(reminder, self.COLS_TO_LOAD) self.beginInsertColumns(QModelIndex(), self.cols_loaded, self.cols_loaded + items_to_fetch - 1) self.cols_loaded += items_to_fetch self.endInsertColumns() def bgcolor(self, state): """Toggle backgroundcolor""" self.bgcolor_enabled = state > 0 self.reset() def get_value(self, index): i = index.row() j = index.column() return self.changes.get((i, j), self._data[i, j]) def data(self, index, role=Qt.DisplayRole): """Cell content""" if not index.isValid(): return to_qvariant() value = self.get_value(index) if role == Qt.DisplayRole: if value is np.ma.masked: return '' else: return to_qvariant(self._format % value) elif role == Qt.TextAlignmentRole: return to_qvariant(int(Qt.AlignCenter|Qt.AlignVCenter)) elif role == Qt.BackgroundColorRole and self.bgcolor_enabled\ and value is not np.ma.masked: hue = self.hue0+\ self.dhue*(self.vmax-self.color_func(value))\ /(self.vmax-self.vmin) hue = float(np.abs(hue)) color = QColor.fromHsvF(hue, self.sat, self.val, self.alp) return to_qvariant(color) elif role == Qt.FontRole: return to_qvariant(get_font('arrayeditor')) return to_qvariant() def setData(self, index, value, role=Qt.EditRole): """Cell content change""" if not index.isValid() or self.readonly: return False i = index.row() j = index.column() value = from_qvariant(value, str) if self._data.dtype.name == "bool": try: val = bool(float(value)) except ValueError: val = value.lower() == "true" elif self._data.dtype.name.startswith("string"): val = str(value) elif self._data.dtype.name.startswith("unicode"): val = to_text_string(value) else: if value.lower().startswith('e') or value.lower().endswith('e'): return False try: val = complex(value) if not val.imag: val = val.real except ValueError as e: QMessageBox.critical(self.dialog, "Error", "Value error: %s" % str(e)) return False try: self.test_array[0] = val # will raise an Exception eventually except OverflowError as e: print(type(e.message)) QMessageBox.critical(self.dialog, "Error", "Overflow error: %s" % e.message) return False # Add change to self.changes self.changes[(i, j)] = val self.emit(SIGNAL("dataChanged(QModelIndex,QModelIndex)"), index, index) if val > self.vmax: self.vmax = val if val < self.vmin: self.vmin = val return True def flags(self, index): """Set editable flag""" if not index.isValid(): return Qt.ItemIsEnabled return Qt.ItemFlags(QAbstractTableModel.flags(self, index)| Qt.ItemIsEditable) def headerData(self, section, orientation, role=Qt.DisplayRole): """Set header data""" if role != Qt.DisplayRole: return to_qvariant() labels = self.xlabels if orientation == Qt.Horizontal else self.ylabels if labels is None: return to_qvariant(int(section)) else: return to_qvariant(labels[section]) class ArrayDelegate(QItemDelegate): """Array Editor Item Delegate""" def __init__(self, dtype, parent=None): QItemDelegate.__init__(self, parent) self.dtype = dtype def createEditor(self, parent, option, index): """Create editor widget""" model = index.model() value = model.get_value(index) if model._data.dtype.name == "bool": value = not value model.setData(index, to_qvariant(value)) return elif value is not np.ma.masked: editor = QLineEdit(parent) editor.setFont(get_font('arrayeditor')) editor.setAlignment(Qt.AlignCenter) if is_number(self.dtype): editor.setValidator(QDoubleValidator(editor)) self.connect(editor, SIGNAL("returnPressed()"), self.commitAndCloseEditor) return editor def commitAndCloseEditor(self): """Commit and close editor""" editor = self.sender() self.emit(SIGNAL("commitData(QWidget*)"), editor) self.emit(SIGNAL("closeEditor(QWidget*)"), editor) def setEditorData(self, editor, index): """Set editor widget's data""" text = from_qvariant(index.model().data(index, Qt.DisplayRole), str) editor.setText(text) #TODO: Implement "Paste" (from clipboard) feature class ArrayView(QTableView): """Array view class""" def __init__(self, parent, model, dtype, shape): QTableView.__init__(self, parent) self.setModel(model) self.setItemDelegate(ArrayDelegate(dtype, self)) total_width = 0 for k in range(shape[1]): total_width += self.columnWidth(k) self.viewport().resize(min(total_width, 1024), self.height()) self.shape = shape self.menu = self.setup_menu() new_shortcut(QKeySequence.Copy, self, self.copy) self.connect(self.horizontalScrollBar(), SIGNAL("valueChanged(int)"), lambda val: self.load_more_data(val, columns=True)) self.connect(self.verticalScrollBar(), SIGNAL("valueChanged(int)"), lambda val: self.load_more_data(val, rows=True)) def load_more_data(self, value, rows=False, columns=False): if rows and value == self.verticalScrollBar().maximum(): self.model().fetch_more(rows=rows) if columns and value == self.horizontalScrollBar().maximum(): self.model().fetch_more(columns=columns) def resize_to_contents(self): """Resize cells to contents""" QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) self.resizeColumnsToContents() self.model().fetch_more(columns=True) self.resizeColumnsToContents() QApplication.restoreOverrideCursor() def setup_menu(self): """Setup context menu""" self.copy_action = create_action(self, _( "Copy"), shortcut=keybinding("Copy"), icon=get_icon('editcopy.png'), triggered=self.copy, context=Qt.WidgetShortcut) menu = QMenu(self) add_actions(menu, [self.copy_action, ]) return menu def contextMenuEvent(self, event): """Reimplement Qt method""" self.menu.popup(event.globalPos()) event.accept() def keyPressEvent(self, event): """Reimplement Qt method""" if event == QKeySequence.Copy: self.copy() else: QTableView.keyPressEvent(self, event) def _sel_to_text(self, cell_range): """Copy an array portion to a unicode string""" row_min, row_max, col_min, col_max = get_idx_rect(cell_range) _data = self.model().get_data() output = io.StringIO() np.savetxt(output, _data[row_min:row_max+1, col_min:col_max+1], delimiter='\t') contents = output.getvalue() output.close() return contents def copy(self): """Copy text to clipboard""" cliptxt = self._sel_to_text( self.selectedIndexes() ) clipboard = QApplication.clipboard() clipboard.setText(cliptxt) class ArrayEditorWidget(QWidget): def __init__(self, parent, data, readonly=False, xlabels=None, ylabels=None): QWidget.__init__(self, parent) self.data = data self.old_data_shape = None if len(self.data.shape) == 1: self.old_data_shape = self.data.shape self.data.shape = (self.data.shape[0], 1) elif len(self.data.shape) == 0: self.old_data_shape = self.data.shape self.data.shape = (1, 1) format = SUPPORTED_FORMATS.get(data.dtype.name, '%s') self.model = ArrayModel(self.data, format=format, xlabels=xlabels, ylabels=ylabels, readonly=readonly, parent=self) self.view = ArrayView(self, self.model, data.dtype, data.shape) btn_layout = QHBoxLayout() btn_layout.setAlignment(Qt.AlignLeft) btn = QPushButton(_( "Format")) # disable format button for int type btn.setEnabled(is_float(data.dtype)) btn_layout.addWidget(btn) self.connect(btn, SIGNAL("clicked()"), self.change_format) btn = QPushButton(_( "Resize")) btn_layout.addWidget(btn) self.connect(btn, SIGNAL("clicked()"), self.view.resize_to_contents) bgcolor = QCheckBox(_( 'Background color')) bgcolor.setChecked(self.model.bgcolor_enabled) bgcolor.setEnabled(self.model.bgcolor_enabled) self.connect(bgcolor, SIGNAL("stateChanged(int)"), self.model.bgcolor) btn_layout.addWidget(bgcolor) layout = QVBoxLayout() layout.addWidget(self.view) layout.addLayout(btn_layout) self.setLayout(layout) def accept_changes(self): """Accept changes""" for (i, j), value in list(self.model.changes.items()): self.data[i, j] = value if self.old_data_shape is not None: self.data.shape = self.old_data_shape def reject_changes(self): """Reject changes""" if self.old_data_shape is not None: self.data.shape = self.old_data_shape def change_format(self): """Change display format""" format, valid = QInputDialog.getText(self, _( 'Format'), _( "Float formatting"), QLineEdit.Normal, self.model.get_format()) if valid: format = str(format) try: format % 1.1 except: QMessageBox.critical(self, _("Error"), _("Format (%s) is incorrect") % format) return self.model.set_format(format) class ArrayEditor(QDialog): """Array Editor Dialog""" def __init__(self, parent=None): QDialog.__init__(self, parent) # Destroying the C++ object right after closing the dialog box, # otherwise it may be garbage-collected in another QThread # (e.g. the editor's analysis thread in Spyder), thus leading to # a segmentation fault on UNIX or an application crash on Windows self.setAttribute(Qt.WA_DeleteOnClose) self.data = None self.arraywidget = None self.stack = None self.layout = None # Values for 3d array editor self.dim_indexes = [{}, {}, {}] self.last_dim = 0 # Adjust this for changing the startup dimension def setup_and_check(self, data, title='', readonly=False, xlabels=None, ylabels=None): """ Setup ArrayEditor: return False if data is not supported, True otherwise """ self.data = data is_record_array = data.dtype.names is not None is_masked_array = isinstance(data, np.ma.MaskedArray) if data.size == 0: self.error(_("Array is empty")) return False if data.ndim > 3: self.error(_("Arrays with more than 3 dimensions " "are not supported")) return False if xlabels is not None and len(xlabels) != self.data.shape[1]: self.error(_("The 'xlabels' argument length " "do no match array column number")) return False if ylabels is not None and len(ylabels) != self.data.shape[0]: self.error(_("The 'ylabels' argument length " "do no match array row number")) return False if not is_record_array: dtn = data.dtype.name if dtn not in SUPPORTED_FORMATS and not dtn.startswith('str') \ and not dtn.startswith('unicode'): arr = _("%s arrays") % data.dtype.name self.error(_("%s are currently not supported") % arr) return False self.layout = QGridLayout() self.setLayout(self.layout) self.setWindowIcon(get_icon('arredit.png')) if title: title = to_text_string(title) + " - " + _("NumPy array") else: title = _("Array editor") if readonly: title += ' (' + _('read only') + ')' self.setWindowTitle(title) self.resize(600, 500) # Stack widget self.stack = QStackedWidget(self) if is_record_array: for name in data.dtype.names: self.stack.addWidget(ArrayEditorWidget(self, data[name], readonly, xlabels, ylabels)) elif is_masked_array: self.stack.addWidget(ArrayEditorWidget(self, data, readonly, xlabels, ylabels)) self.stack.addWidget(ArrayEditorWidget(self, data.data, readonly, xlabels, ylabels)) self.stack.addWidget(ArrayEditorWidget(self, data.mask, readonly, xlabels, ylabels)) elif data.ndim == 3: pass else: self.stack.addWidget(ArrayEditorWidget(self, data, readonly, xlabels, ylabels)) self.arraywidget = self.stack.currentWidget() self.connect(self.stack, SIGNAL('currentChanged(int)'), self.current_widget_changed) self.layout.addWidget(self.stack, 1, 0) # Buttons configuration btn_layout = QHBoxLayout() if is_record_array or is_masked_array or data.ndim == 3: if is_record_array: btn_layout.addWidget(QLabel(_("Record array fields:"))) names = [] for name in data.dtype.names: field = data.dtype.fields[name] text = name if len(field) >= 3: title = field[2] if not is_text_string(title): title = repr(title) text += ' - '+title names.append(text) else: names = [_('Masked data'), _('Data'), _('Mask')] if data.ndim == 3: # QSpinBox self.index_spin = QSpinBox(self, keyboardTracking=False) self.connect(self.index_spin, SIGNAL('valueChanged(int)'), self.change_active_widget) # QComboBox names = [str(i) for i in range(3)] ra_combo = QComboBox(self) ra_combo.addItems(names) self.connect(ra_combo, SIGNAL('currentIndexChanged(int)'), self.current_dim_changed) # Adding the widgets to layout label = QLabel(_("Axis:")) btn_layout.addWidget(label) btn_layout.addWidget(ra_combo) self.shape_label = QLabel() btn_layout.addWidget(self.shape_label) label = QLabel(_("Index:")) btn_layout.addWidget(label) btn_layout.addWidget(self.index_spin) self.slicing_label = QLabel() btn_layout.addWidget(self.slicing_label) # set the widget to display when launched self.current_dim_changed(self.last_dim) else: ra_combo = QComboBox(self) self.connect(ra_combo, SIGNAL('currentIndexChanged(int)'), self.stack.setCurrentIndex) ra_combo.addItems(names) btn_layout.addWidget(ra_combo) if is_masked_array: label = QLabel(_("Warning: changes are applied separately")) label.setToolTip(_("For performance reasons, changes applied "\ "to masked array won't be reflected in "\ "array's data (and vice-versa).")) btn_layout.addWidget(label) btn_layout.addStretch() bbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) self.connect(bbox, SIGNAL("accepted()"), SLOT("accept()")) self.connect(bbox, SIGNAL("rejected()"), SLOT("reject()")) btn_layout.addWidget(bbox) self.layout.addLayout(btn_layout, 2, 0) self.setMinimumSize(400, 300) # Make the dialog act as a window self.setWindowFlags(Qt.Window) return True def current_widget_changed(self, index): self.arraywidget = self.stack.widget(index) def change_active_widget(self, index): """ This is implemented for handling negative values in index for 3d arrays, to give the same behavior as slicing """ string_index = [':']*3 string_index[self.last_dim] = '%i' self.slicing_label.setText((r"Slicing: [" + ", ".join(string_index) + "]") % index) if index < 0: data_index = self.data.shape[self.last_dim] + index else: data_index = index slice_index = [slice(None)]*3 slice_index[self.last_dim] = data_index stack_index = self.dim_indexes[self.last_dim].get(data_index) if stack_index == None: stack_index = self.stack.count() self.stack.addWidget(ArrayEditorWidget(self, self.data[slice_index])) self.dim_indexes[self.last_dim][data_index] = stack_index self.stack.update() self.stack.setCurrentIndex(stack_index) def current_dim_changed(self, index): """ This change the active axis the array editor is plotting over in 3D """ self.last_dim = index string_size = ['%i']*3 string_size[index] = '%i' self.shape_label.setText(('Shape: (' + ', '.join(string_size) + ') ') % self.data.shape) if self.index_spin.value() != 0: self.index_spin.setValue(0) else: # this is done since if the value is currently 0 it does not emit # currentIndexChanged(int) self.change_active_widget(0) self.index_spin.setRange(-self.data.shape[index], self.data.shape[index]-1) def accept(self): """Reimplement Qt method""" for index in range(self.stack.count()): self.stack.widget(index).accept_changes() QDialog.accept(self) def get_value(self): """Return modified array -- this is *not* a copy""" # It is import to avoid accessing Qt C++ object as it has probably # already been destroyed, due to the Qt.WA_DeleteOnClose attribute return self.data def error(self, message): """An error occured, closing the dialog box""" QMessageBox.critical(self, _("Array editor"), message) self.setAttribute(Qt.WA_DeleteOnClose) self.reject() def reject(self): """Reimplement Qt method""" if self.arraywidget is not None: for index in range(self.stack.count()): self.stack.widget(index).reject_changes() QDialog.reject(self) def test_edit(data, title="", xlabels=None, ylabels=None, readonly=False, parent=None): """Test subroutine""" dlg = ArrayEditor(parent) if dlg.setup_and_check(data, title, xlabels=xlabels, ylabels=ylabels, readonly=readonly) and dlg.exec_(): return dlg.get_value() else: import sys sys.exit() def test(): """Array editor test""" _app = qapplication() arr = np.array(["kjrekrjkejr"]) print("out:", test_edit(arr, "string array")) from spyderlib.py3compat import u arr = np.array([u("kjrekrjkejr")]) print("out:", test_edit(arr, "unicode array")) arr = np.ma.array([[1, 0], [1, 0]], mask=[[True, False], [False, False]]) print("out:", test_edit(arr, "masked array")) arr = np.zeros((2, 2), {'names': ('red', 'green', 'blue'), 'formats': (np.float32, np.float32, np.float32)}) print("out:", test_edit(arr, "record array")) arr = np.array([(0, 0.0), (0, 0.0), (0, 0.0)], dtype=[(('title 1', 'x'), '|i1'), (('title 2', 'y'), '>f4')]) print("out:", test_edit(arr, "record array with titles")) arr = np.random.rand(5, 5) print("out:", test_edit(arr, "float array", xlabels=['a', 'b', 'c', 'd', 'e'])) arr = np.round(np.random.rand(5, 5)*10)+\ np.round(np.random.rand(5, 5)*10)*1j print("out:", test_edit(arr, "complex array", xlabels=np.linspace(-12, 12, 5), ylabels=np.linspace(-12, 12, 5))) arr_in = np.array([True, False, True]) print("in:", arr_in) arr_out = test_edit(arr_in, "bool array") print("out:", arr_out) print(arr_in is arr_out) arr = np.array([1, 2, 3], dtype="int8") print("out:", test_edit(arr, "int array")) arr = np.zeros((3,3,4)) arr[0,0,0]=1 arr[0,0,1]=2 arr[0,0,2]=3 print("out:", test_edit(arr)) if __name__ == "__main__": test()