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