1# -*- coding: utf-8 -*- 2# 3# Copyright © Spyder Project Contributors 4# Licensed under the terms of the MIT License 5# (see spyder/__init__.py for details) 6 7""" 8NumPy Array Editor Dialog based on Qt 9""" 10 11# pylint: disable=C0103 12# pylint: disable=R0903 13# pylint: disable=R0911 14# pylint: disable=R0201 15 16# Standard library imports 17from __future__ import print_function 18 19# Third party imports 20from qtpy.compat import from_qvariant, to_qvariant 21from qtpy.QtCore import (QAbstractTableModel, QItemSelection, QLocale, 22 QItemSelectionRange, QModelIndex, Qt, Slot) 23from qtpy.QtGui import QColor, QCursor, QDoubleValidator, QKeySequence 24from qtpy.QtWidgets import (QAbstractItemDelegate, QApplication, QCheckBox, 25 QComboBox, QDialog, QDialogButtonBox, QGridLayout, 26 QHBoxLayout, QInputDialog, QItemDelegate, QLabel, 27 QLineEdit, QMenu, QMessageBox, QPushButton, 28 QSpinBox, QStackedWidget, QTableView, QVBoxLayout, 29 QWidget) 30import numpy as np 31 32# Local imports 33from spyder.config.base import _ 34from spyder.config.fonts import DEFAULT_SMALL_DELTA 35from spyder.config.gui import get_font, config_shortcut 36from spyder.py3compat import (io, is_binary_string, is_string, 37 is_text_string, PY3, to_binary_string, 38 to_text_string) 39from spyder.utils import icon_manager as ima 40from spyder.utils.qthelpers import add_actions, create_action, keybinding 41 42 43# Note: string and unicode data types will be formatted with '%s' (see below) 44SUPPORTED_FORMATS = { 45 'single': '%.6g', 46 'double': '%.6g', 47 'float_': '%.6g', 48 'longfloat': '%.6g', 49 'float16': '%.6g', 50 'float32': '%.6g', 51 'float64': '%.6g', 52 'float96': '%.6g', 53 'float128': '%.6g', 54 'csingle': '%r', 55 'complex_': '%r', 56 'clongfloat': '%r', 57 'complex64': '%r', 58 'complex128': '%r', 59 'complex192': '%r', 60 'complex256': '%r', 61 'byte': '%d', 62 'bytes8': '%s', 63 'short': '%d', 64 'intc': '%d', 65 'int_': '%d', 66 'longlong': '%d', 67 'intp': '%d', 68 'int8': '%d', 69 'int16': '%d', 70 'int32': '%d', 71 'int64': '%d', 72 'ubyte': '%d', 73 'ushort': '%d', 74 'uintc': '%d', 75 'uint': '%d', 76 'ulonglong': '%d', 77 'uintp': '%d', 78 'uint8': '%d', 79 'uint16': '%d', 80 'uint32': '%d', 81 'uint64': '%d', 82 'bool_': '%r', 83 'bool8': '%r', 84 'bool': '%r', 85 } 86 87 88LARGE_SIZE = 5e5 89LARGE_NROWS = 1e5 90LARGE_COLS = 60 91 92 93#============================================================================== 94# Utility functions 95#============================================================================== 96def is_float(dtype): 97 """Return True if datatype dtype is a float kind""" 98 return ('float' in dtype.name) or dtype.name in ['single', 'double'] 99 100 101def is_number(dtype): 102 """Return True is datatype dtype is a number kind""" 103 return is_float(dtype) or ('int' in dtype.name) or ('long' in dtype.name) \ 104 or ('short' in dtype.name) 105 106 107def get_idx_rect(index_list): 108 """Extract the boundaries from a list of indexes""" 109 rows, cols = list(zip(*[(i.row(), i.column()) for i in index_list])) 110 return ( min(rows), max(rows), min(cols), max(cols) ) 111 112 113#============================================================================== 114# Main classes 115#============================================================================== 116class ArrayModel(QAbstractTableModel): 117 """Array Editor Table Model""" 118 119 ROWS_TO_LOAD = 500 120 COLS_TO_LOAD = 40 121 122 def __init__(self, data, format="%.6g", xlabels=None, ylabels=None, 123 readonly=False, parent=None): 124 QAbstractTableModel.__init__(self) 125 126 self.dialog = parent 127 self.changes = {} 128 self.xlabels = xlabels 129 self.ylabels = ylabels 130 self.readonly = readonly 131 self.test_array = np.array([0], dtype=data.dtype) 132 133 # for complex numbers, shading will be based on absolute value 134 # but for all other types it will be the real part 135 if data.dtype in (np.complex64, np.complex128): 136 self.color_func = np.abs 137 else: 138 self.color_func = np.real 139 140 # Backgroundcolor settings 141 huerange = [.66, .99] # Hue 142 self.sat = .7 # Saturation 143 self.val = 1. # Value 144 self.alp = .6 # Alpha-channel 145 146 self._data = data 147 self._format = format 148 149 self.total_rows = self._data.shape[0] 150 self.total_cols = self._data.shape[1] 151 size = self.total_rows * self.total_cols 152 153 try: 154 self.vmin = np.nanmin(self.color_func(data)) 155 self.vmax = np.nanmax(self.color_func(data)) 156 if self.vmax == self.vmin: 157 self.vmin -= 1 158 self.hue0 = huerange[0] 159 self.dhue = huerange[1]-huerange[0] 160 self.bgcolor_enabled = True 161 except (TypeError, ValueError): 162 self.vmin = None 163 self.vmax = None 164 self.hue0 = None 165 self.dhue = None 166 self.bgcolor_enabled = False 167 168 # Use paging when the total size, number of rows or number of 169 # columns is too large 170 if size > LARGE_SIZE: 171 self.rows_loaded = self.ROWS_TO_LOAD 172 self.cols_loaded = self.COLS_TO_LOAD 173 else: 174 if self.total_rows > LARGE_NROWS: 175 self.rows_loaded = self.ROWS_TO_LOAD 176 else: 177 self.rows_loaded = self.total_rows 178 if self.total_cols > LARGE_COLS: 179 self.cols_loaded = self.COLS_TO_LOAD 180 else: 181 self.cols_loaded = self.total_cols 182 183 def get_format(self): 184 """Return current format""" 185 # Avoid accessing the private attribute _format from outside 186 return self._format 187 188 def get_data(self): 189 """Return data""" 190 return self._data 191 192 def set_format(self, format): 193 """Change display format""" 194 self._format = format 195 self.reset() 196 197 def columnCount(self, qindex=QModelIndex()): 198 """Array column number""" 199 if self.total_cols <= self.cols_loaded: 200 return self.total_cols 201 else: 202 return self.cols_loaded 203 204 def rowCount(self, qindex=QModelIndex()): 205 """Array row number""" 206 if self.total_rows <= self.rows_loaded: 207 return self.total_rows 208 else: 209 return self.rows_loaded 210 211 def can_fetch_more(self, rows=False, columns=False): 212 if rows: 213 if self.total_rows > self.rows_loaded: 214 return True 215 else: 216 return False 217 if columns: 218 if self.total_cols > self.cols_loaded: 219 return True 220 else: 221 return False 222 223 def fetch_more(self, rows=False, columns=False): 224 if self.can_fetch_more(rows=rows): 225 reminder = self.total_rows - self.rows_loaded 226 items_to_fetch = min(reminder, self.ROWS_TO_LOAD) 227 self.beginInsertRows(QModelIndex(), self.rows_loaded, 228 self.rows_loaded + items_to_fetch - 1) 229 self.rows_loaded += items_to_fetch 230 self.endInsertRows() 231 if self.can_fetch_more(columns=columns): 232 reminder = self.total_cols - self.cols_loaded 233 items_to_fetch = min(reminder, self.COLS_TO_LOAD) 234 self.beginInsertColumns(QModelIndex(), self.cols_loaded, 235 self.cols_loaded + items_to_fetch - 1) 236 self.cols_loaded += items_to_fetch 237 self.endInsertColumns() 238 239 def bgcolor(self, state): 240 """Toggle backgroundcolor""" 241 self.bgcolor_enabled = state > 0 242 self.reset() 243 244 def get_value(self, index): 245 i = index.row() 246 j = index.column() 247 if len(self._data.shape) == 1: 248 value = self._data[j] 249 else: 250 value = self._data[i, j] 251 return self.changes.get((i, j), value) 252 253 def data(self, index, role=Qt.DisplayRole): 254 """Cell content""" 255 if not index.isValid(): 256 return to_qvariant() 257 value = self.get_value(index) 258 if is_binary_string(value): 259 try: 260 value = to_text_string(value, 'utf8') 261 except: 262 pass 263 if role == Qt.DisplayRole: 264 if value is np.ma.masked: 265 return '' 266 else: 267 try: 268 return to_qvariant(self._format % value) 269 except TypeError: 270 self.readonly = True 271 return repr(value) 272 elif role == Qt.TextAlignmentRole: 273 return to_qvariant(int(Qt.AlignCenter|Qt.AlignVCenter)) 274 elif role == Qt.BackgroundColorRole and self.bgcolor_enabled \ 275 and value is not np.ma.masked: 276 try: 277 hue = (self.hue0 + 278 self.dhue * (float(self.vmax) - self.color_func(value)) 279 / (float(self.vmax) - self.vmin)) 280 hue = float(np.abs(hue)) 281 color = QColor.fromHsvF(hue, self.sat, self.val, self.alp) 282 return to_qvariant(color) 283 except TypeError: 284 return to_qvariant() 285 elif role == Qt.FontRole: 286 return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) 287 return to_qvariant() 288 289 def setData(self, index, value, role=Qt.EditRole): 290 """Cell content change""" 291 if not index.isValid() or self.readonly: 292 return False 293 i = index.row() 294 j = index.column() 295 value = from_qvariant(value, str) 296 dtype = self._data.dtype.name 297 if dtype == "bool": 298 try: 299 val = bool(float(value)) 300 except ValueError: 301 val = value.lower() == "true" 302 elif dtype.startswith("string") or dtype.startswith("bytes"): 303 val = to_binary_string(value, 'utf8') 304 elif dtype.startswith("unicode") or dtype.startswith("str"): 305 val = to_text_string(value) 306 else: 307 if value.lower().startswith('e') or value.lower().endswith('e'): 308 return False 309 try: 310 val = complex(value) 311 if not val.imag: 312 val = val.real 313 except ValueError as e: 314 QMessageBox.critical(self.dialog, "Error", 315 "Value error: %s" % str(e)) 316 return False 317 try: 318 self.test_array[0] = val # will raise an Exception eventually 319 except OverflowError as e: 320 print("OverflowError: " + str(e)) # spyder: test-skip 321 QMessageBox.critical(self.dialog, "Error", 322 "Overflow error: %s" % str(e)) 323 return False 324 325 # Add change to self.changes 326 self.changes[(i, j)] = val 327 self.dataChanged.emit(index, index) 328 if not is_string(val): 329 if val > self.vmax: 330 self.vmax = val 331 if val < self.vmin: 332 self.vmin = val 333 return True 334 335 def flags(self, index): 336 """Set editable flag""" 337 if not index.isValid(): 338 return Qt.ItemIsEnabled 339 return Qt.ItemFlags(QAbstractTableModel.flags(self, index)| 340 Qt.ItemIsEditable) 341 342 def headerData(self, section, orientation, role=Qt.DisplayRole): 343 """Set header data""" 344 if role != Qt.DisplayRole: 345 return to_qvariant() 346 labels = self.xlabels if orientation == Qt.Horizontal else self.ylabels 347 if labels is None: 348 return to_qvariant(int(section)) 349 else: 350 return to_qvariant(labels[section]) 351 352 def reset(self): 353 self.beginResetModel() 354 self.endResetModel() 355 356 357class ArrayDelegate(QItemDelegate): 358 """Array Editor Item Delegate""" 359 def __init__(self, dtype, parent=None): 360 QItemDelegate.__init__(self, parent) 361 self.dtype = dtype 362 363 def createEditor(self, parent, option, index): 364 """Create editor widget""" 365 model = index.model() 366 value = model.get_value(index) 367 if model._data.dtype.name == "bool": 368 value = not value 369 model.setData(index, to_qvariant(value)) 370 return 371 elif value is not np.ma.masked: 372 editor = QLineEdit(parent) 373 editor.setFont(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) 374 editor.setAlignment(Qt.AlignCenter) 375 if is_number(self.dtype): 376 validator = QDoubleValidator(editor) 377 validator.setLocale(QLocale('C')) 378 editor.setValidator(validator) 379 editor.returnPressed.connect(self.commitAndCloseEditor) 380 return editor 381 382 def commitAndCloseEditor(self): 383 """Commit and close editor""" 384 editor = self.sender() 385 # Avoid a segfault with PyQt5. Variable value won't be changed 386 # but at least Spyder won't crash. It seems generated by a 387 # bug in sip. See 388 # http://comments.gmane.org/gmane.comp.python.pyqt-pykde/26544 389 try: 390 self.commitData.emit(editor) 391 except AttributeError: 392 pass 393 self.closeEditor.emit(editor, QAbstractItemDelegate.NoHint) 394 395 def setEditorData(self, editor, index): 396 """Set editor widget's data""" 397 text = from_qvariant(index.model().data(index, Qt.DisplayRole), str) 398 editor.setText(text) 399 400 401#TODO: Implement "Paste" (from clipboard) feature 402class ArrayView(QTableView): 403 """Array view class""" 404 def __init__(self, parent, model, dtype, shape): 405 QTableView.__init__(self, parent) 406 407 self.setModel(model) 408 self.setItemDelegate(ArrayDelegate(dtype, self)) 409 total_width = 0 410 for k in range(shape[1]): 411 total_width += self.columnWidth(k) 412 self.viewport().resize(min(total_width, 1024), self.height()) 413 self.shape = shape 414 self.menu = self.setup_menu() 415 config_shortcut(self.copy, context='variable_explorer', name='copy', 416 parent=self) 417 self.horizontalScrollBar().valueChanged.connect( 418 lambda val: self.load_more_data(val, columns=True)) 419 self.verticalScrollBar().valueChanged.connect( 420 lambda val: self.load_more_data(val, rows=True)) 421 422 def load_more_data(self, value, rows=False, columns=False): 423 old_selection = self.selectionModel().selection() 424 old_rows_loaded = old_cols_loaded = None 425 426 if rows and value == self.verticalScrollBar().maximum(): 427 old_rows_loaded = self.model().rows_loaded 428 self.model().fetch_more(rows=rows) 429 430 if columns and value == self.horizontalScrollBar().maximum(): 431 old_cols_loaded = self.model().cols_loaded 432 self.model().fetch_more(columns=columns) 433 434 if old_rows_loaded is not None or old_cols_loaded is not None: 435 # if we've changed anything, update selection 436 new_selection = QItemSelection() 437 for part in old_selection: 438 top = part.top() 439 bottom = part.bottom() 440 if (old_rows_loaded is not None and 441 top == 0 and bottom == (old_rows_loaded-1)): 442 # complete column selected (so expand it to match updated range) 443 bottom = self.model().rows_loaded-1 444 left = part.left() 445 right = part.right() 446 if (old_cols_loaded is not None 447 and left == 0 and right == (old_cols_loaded-1)): 448 # compete row selected (so expand it to match updated range) 449 right = self.model().cols_loaded-1 450 top_left = self.model().index(top, left) 451 bottom_right = self.model().index(bottom, right) 452 part = QItemSelectionRange(top_left, bottom_right) 453 new_selection.append(part) 454 self.selectionModel().select(new_selection, self.selectionModel().ClearAndSelect) 455 456 457 def resize_to_contents(self): 458 """Resize cells to contents""" 459 QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) 460 self.resizeColumnsToContents() 461 self.model().fetch_more(columns=True) 462 self.resizeColumnsToContents() 463 QApplication.restoreOverrideCursor() 464 465 def setup_menu(self): 466 """Setup context menu""" 467 self.copy_action = create_action(self, _('Copy'), 468 shortcut=keybinding('Copy'), 469 icon=ima.icon('editcopy'), 470 triggered=self.copy, 471 context=Qt.WidgetShortcut) 472 menu = QMenu(self) 473 add_actions(menu, [self.copy_action, ]) 474 return menu 475 476 def contextMenuEvent(self, event): 477 """Reimplement Qt method""" 478 self.menu.popup(event.globalPos()) 479 event.accept() 480 481 def keyPressEvent(self, event): 482 """Reimplement Qt method""" 483 if event == QKeySequence.Copy: 484 self.copy() 485 else: 486 QTableView.keyPressEvent(self, event) 487 488 def _sel_to_text(self, cell_range): 489 """Copy an array portion to a unicode string""" 490 if not cell_range: 491 return 492 row_min, row_max, col_min, col_max = get_idx_rect(cell_range) 493 if col_min == 0 and col_max == (self.model().cols_loaded-1): 494 # we've selected a whole column. It isn't possible to 495 # select only the first part of a column without loading more, 496 # so we can treat it as intentional and copy the whole thing 497 col_max = self.model().total_cols-1 498 if row_min == 0 and row_max == (self.model().rows_loaded-1): 499 row_max = self.model().total_rows-1 500 501 _data = self.model().get_data() 502 if PY3: 503 output = io.BytesIO() 504 else: 505 output = io.StringIO() 506 try: 507 np.savetxt(output, _data[row_min:row_max+1, col_min:col_max+1], 508 delimiter='\t', fmt=self.model().get_format()) 509 except: 510 QMessageBox.warning(self, _("Warning"), 511 _("It was not possible to copy values for " 512 "this array")) 513 return 514 contents = output.getvalue().decode('utf-8') 515 output.close() 516 return contents 517 518 @Slot() 519 def copy(self): 520 """Copy text to clipboard""" 521 cliptxt = self._sel_to_text( self.selectedIndexes() ) 522 clipboard = QApplication.clipboard() 523 clipboard.setText(cliptxt) 524 525 526class ArrayEditorWidget(QWidget): 527 def __init__(self, parent, data, readonly=False, 528 xlabels=None, ylabels=None): 529 QWidget.__init__(self, parent) 530 self.data = data 531 self.old_data_shape = None 532 if len(self.data.shape) == 1: 533 self.old_data_shape = self.data.shape 534 self.data.shape = (self.data.shape[0], 1) 535 elif len(self.data.shape) == 0: 536 self.old_data_shape = self.data.shape 537 self.data.shape = (1, 1) 538 539 format = SUPPORTED_FORMATS.get(data.dtype.name, '%s') 540 self.model = ArrayModel(self.data, format=format, xlabels=xlabels, 541 ylabels=ylabels, readonly=readonly, parent=self) 542 self.view = ArrayView(self, self.model, data.dtype, data.shape) 543 544 btn_layout = QHBoxLayout() 545 btn_layout.setAlignment(Qt.AlignLeft) 546 btn = QPushButton(_( "Format")) 547 # disable format button for int type 548 btn.setEnabled(is_float(data.dtype)) 549 btn_layout.addWidget(btn) 550 btn.clicked.connect(self.change_format) 551 btn = QPushButton(_( "Resize")) 552 btn_layout.addWidget(btn) 553 btn.clicked.connect(self.view.resize_to_contents) 554 bgcolor = QCheckBox(_( 'Background color')) 555 bgcolor.setChecked(self.model.bgcolor_enabled) 556 bgcolor.setEnabled(self.model.bgcolor_enabled) 557 bgcolor.stateChanged.connect(self.model.bgcolor) 558 btn_layout.addWidget(bgcolor) 559 560 layout = QVBoxLayout() 561 layout.addWidget(self.view) 562 layout.addLayout(btn_layout) 563 self.setLayout(layout) 564 565 def accept_changes(self): 566 """Accept changes""" 567 for (i, j), value in list(self.model.changes.items()): 568 self.data[i, j] = value 569 if self.old_data_shape is not None: 570 self.data.shape = self.old_data_shape 571 572 def reject_changes(self): 573 """Reject changes""" 574 if self.old_data_shape is not None: 575 self.data.shape = self.old_data_shape 576 577 def change_format(self): 578 """Change display format""" 579 format, valid = QInputDialog.getText(self, _( 'Format'), 580 _( "Float formatting"), 581 QLineEdit.Normal, self.model.get_format()) 582 if valid: 583 format = str(format) 584 try: 585 format % 1.1 586 except: 587 QMessageBox.critical(self, _("Error"), 588 _("Format (%s) is incorrect") % format) 589 return 590 self.model.set_format(format) 591 592 593class ArrayEditor(QDialog): 594 """Array Editor Dialog""" 595 def __init__(self, parent=None): 596 QDialog.__init__(self, parent) 597 598 # Destroying the C++ object right after closing the dialog box, 599 # otherwise it may be garbage-collected in another QThread 600 # (e.g. the editor's analysis thread in Spyder), thus leading to 601 # a segmentation fault on UNIX or an application crash on Windows 602 self.setAttribute(Qt.WA_DeleteOnClose) 603 604 self.data = None 605 self.arraywidget = None 606 self.stack = None 607 self.layout = None 608 # Values for 3d array editor 609 self.dim_indexes = [{}, {}, {}] 610 self.last_dim = 0 # Adjust this for changing the startup dimension 611 612 def setup_and_check(self, data, title='', readonly=False, 613 xlabels=None, ylabels=None): 614 """ 615 Setup ArrayEditor: 616 return False if data is not supported, True otherwise 617 """ 618 self.data = data 619 self.data.flags.writeable = True 620 is_record_array = data.dtype.names is not None 621 is_masked_array = isinstance(data, np.ma.MaskedArray) 622 623 if data.ndim > 3: 624 self.error(_("Arrays with more than 3 dimensions are not " 625 "supported")) 626 return False 627 if xlabels is not None and len(xlabels) != self.data.shape[1]: 628 self.error(_("The 'xlabels' argument length do no match array " 629 "column number")) 630 return False 631 if ylabels is not None and len(ylabels) != self.data.shape[0]: 632 self.error(_("The 'ylabels' argument length do no match array row " 633 "number")) 634 return False 635 if not is_record_array: 636 dtn = data.dtype.name 637 if dtn not in SUPPORTED_FORMATS and not dtn.startswith('str') \ 638 and not dtn.startswith('unicode'): 639 arr = _("%s arrays") % data.dtype.name 640 self.error(_("%s are currently not supported") % arr) 641 return False 642 643 self.layout = QGridLayout() 644 self.setLayout(self.layout) 645 self.setWindowIcon(ima.icon('arredit')) 646 if title: 647 title = to_text_string(title) + " - " + _("NumPy array") 648 else: 649 title = _("Array editor") 650 if readonly: 651 title += ' (' + _('read only') + ')' 652 self.setWindowTitle(title) 653 self.resize(600, 500) 654 655 # Stack widget 656 self.stack = QStackedWidget(self) 657 if is_record_array: 658 for name in data.dtype.names: 659 self.stack.addWidget(ArrayEditorWidget(self, data[name], 660 readonly, xlabels, ylabels)) 661 elif is_masked_array: 662 self.stack.addWidget(ArrayEditorWidget(self, data, readonly, 663 xlabels, ylabels)) 664 self.stack.addWidget(ArrayEditorWidget(self, data.data, readonly, 665 xlabels, ylabels)) 666 self.stack.addWidget(ArrayEditorWidget(self, data.mask, readonly, 667 xlabels, ylabels)) 668 elif data.ndim == 3: 669 pass 670 else: 671 self.stack.addWidget(ArrayEditorWidget(self, data, readonly, 672 xlabels, ylabels)) 673 self.arraywidget = self.stack.currentWidget() 674 self.stack.currentChanged.connect(self.current_widget_changed) 675 self.layout.addWidget(self.stack, 1, 0) 676 677 # Buttons configuration 678 btn_layout = QHBoxLayout() 679 if is_record_array or is_masked_array or data.ndim == 3: 680 if is_record_array: 681 btn_layout.addWidget(QLabel(_("Record array fields:"))) 682 names = [] 683 for name in data.dtype.names: 684 field = data.dtype.fields[name] 685 text = name 686 if len(field) >= 3: 687 title = field[2] 688 if not is_text_string(title): 689 title = repr(title) 690 text += ' - '+title 691 names.append(text) 692 else: 693 names = [_('Masked data'), _('Data'), _('Mask')] 694 if data.ndim == 3: 695 # QSpinBox 696 self.index_spin = QSpinBox(self, keyboardTracking=False) 697 self.index_spin.valueChanged.connect(self.change_active_widget) 698 # QComboBox 699 names = [str(i) for i in range(3)] 700 ra_combo = QComboBox(self) 701 ra_combo.addItems(names) 702 ra_combo.currentIndexChanged.connect(self.current_dim_changed) 703 # Adding the widgets to layout 704 label = QLabel(_("Axis:")) 705 btn_layout.addWidget(label) 706 btn_layout.addWidget(ra_combo) 707 self.shape_label = QLabel() 708 btn_layout.addWidget(self.shape_label) 709 label = QLabel(_("Index:")) 710 btn_layout.addWidget(label) 711 btn_layout.addWidget(self.index_spin) 712 self.slicing_label = QLabel() 713 btn_layout.addWidget(self.slicing_label) 714 # set the widget to display when launched 715 self.current_dim_changed(self.last_dim) 716 else: 717 ra_combo = QComboBox(self) 718 ra_combo.currentIndexChanged.connect(self.stack.setCurrentIndex) 719 ra_combo.addItems(names) 720 btn_layout.addWidget(ra_combo) 721 if is_masked_array: 722 label = QLabel(_("<u>Warning</u>: changes are applied separately")) 723 label.setToolTip(_("For performance reasons, changes applied "\ 724 "to masked array won't be reflected in "\ 725 "array's data (and vice-versa).")) 726 btn_layout.addWidget(label) 727 btn_layout.addStretch() 728 bbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) 729 bbox.accepted.connect(self.accept) 730 bbox.rejected.connect(self.reject) 731 btn_layout.addWidget(bbox) 732 self.layout.addLayout(btn_layout, 2, 0) 733 734 self.setMinimumSize(400, 300) 735 736 # Make the dialog act as a window 737 self.setWindowFlags(Qt.Window) 738 739 return True 740 741 def current_widget_changed(self, index): 742 self.arraywidget = self.stack.widget(index) 743 744 def change_active_widget(self, index): 745 """ 746 This is implemented for handling negative values in index for 747 3d arrays, to give the same behavior as slicing 748 """ 749 string_index = [':']*3 750 string_index[self.last_dim] = '<font color=red>%i</font>' 751 self.slicing_label.setText((r"Slicing: [" + ", ".join(string_index) + 752 "]") % index) 753 if index < 0: 754 data_index = self.data.shape[self.last_dim] + index 755 else: 756 data_index = index 757 slice_index = [slice(None)]*3 758 slice_index[self.last_dim] = data_index 759 760 stack_index = self.dim_indexes[self.last_dim].get(data_index) 761 if stack_index == None: 762 stack_index = self.stack.count() 763 try: 764 self.stack.addWidget(ArrayEditorWidget(self, 765 self.data[slice_index])) 766 except IndexError: # Handle arrays of size 0 in one axis 767 self.stack.addWidget(ArrayEditorWidget(self, self.data)) 768 self.dim_indexes[self.last_dim][data_index] = stack_index 769 self.stack.update() 770 self.stack.setCurrentIndex(stack_index) 771 772 def current_dim_changed(self, index): 773 """ 774 This change the active axis the array editor is plotting over 775 in 3D 776 """ 777 self.last_dim = index 778 string_size = ['%i']*3 779 string_size[index] = '<font color=red>%i</font>' 780 self.shape_label.setText(('Shape: (' + ', '.join(string_size) + 781 ') ') % self.data.shape) 782 if self.index_spin.value() != 0: 783 self.index_spin.setValue(0) 784 else: 785 # this is done since if the value is currently 0 it does not emit 786 # currentIndexChanged(int) 787 self.change_active_widget(0) 788 self.index_spin.setRange(-self.data.shape[index], 789 self.data.shape[index]-1) 790 791 @Slot() 792 def accept(self): 793 """Reimplement Qt method""" 794 for index in range(self.stack.count()): 795 self.stack.widget(index).accept_changes() 796 QDialog.accept(self) 797 798 def get_value(self): 799 """Return modified array -- this is *not* a copy""" 800 # It is import to avoid accessing Qt C++ object as it has probably 801 # already been destroyed, due to the Qt.WA_DeleteOnClose attribute 802 return self.data 803 804 def error(self, message): 805 """An error occured, closing the dialog box""" 806 QMessageBox.critical(self, _("Array editor"), message) 807 self.setAttribute(Qt.WA_DeleteOnClose) 808 self.reject() 809 810 @Slot() 811 def reject(self): 812 """Reimplement Qt method""" 813 if self.arraywidget is not None: 814 for index in range(self.stack.count()): 815 self.stack.widget(index).reject_changes() 816 QDialog.reject(self) 817