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 Matrix/Array Builder Widget.
9"""
10
11# TODO:
12# -Set font based on caller? editor console? and adjust size of widget
13# -Fix positioning
14# -Use the same font as editor/console?
15
16# Standard library imports
17from __future__ import division
18import re
19
20# Third party imports
21from qtpy.QtCore import QEvent, QPoint, Qt
22from qtpy.QtWidgets import (QDialog, QHBoxLayout, QLineEdit, QTableWidget,
23                            QTableWidgetItem, QToolButton, QToolTip,
24                            QWidget)
25
26# Local imports
27from spyder.config.base import _
28from spyder.utils import icon_manager as ima
29from spyder.widgets.helperwidgets import HelperToolButton
30
31
32# Constants
33SHORTCUT_TABLE = "Ctrl+M"
34SHORTCUT_INLINE = "Ctrl+Alt+M"
35ELEMENT_SEPARATOR = ', '
36ROW_SEPARATOR = ';'
37BRACES = '], ['
38NAN_VALUES = ['nan', 'NAN', 'NaN', 'Na', 'NA', 'na']
39
40
41class NumpyArrayInline(QLineEdit):
42    def __init__(self, parent):
43        QLineEdit.__init__(self, parent)
44        self._parent = parent
45
46    def keyPressEvent(self, event):
47        """
48        Qt override.
49        """
50        if event.key() in [Qt.Key_Enter, Qt.Key_Return]:
51            self._parent.process_text()
52            if self._parent.is_valid():
53                self._parent.keyPressEvent(event)
54        else:
55            QLineEdit.keyPressEvent(self, event)
56
57    # to catch the Tab key event
58    def event(self, event):
59        """
60        Qt override.
61
62        This is needed to be able to intercept the Tab key press event.
63        """
64        if event.type() == QEvent.KeyPress:
65            if (event.key() == Qt.Key_Tab or event.key() == Qt.Key_Space):
66                text = self.text()
67                cursor = self.cursorPosition()
68                # fix to include in "undo/redo" history
69                if cursor != 0 and text[cursor-1] == ' ':
70                    text = text[:cursor-1] + ROW_SEPARATOR + ' ' +\
71                        text[cursor:]
72                else:
73                    text = text[:cursor] + ' ' + text[cursor:]
74                self.setCursorPosition(cursor)
75                self.setText(text)
76                self.setCursorPosition(cursor + 1)
77                return False
78        return QWidget.event(self, event)
79
80
81class NumpyArrayTable(QTableWidget):
82    def __init__(self, parent):
83        QTableWidget.__init__(self, parent)
84        self._parent = parent
85        self.setRowCount(2)
86        self.setColumnCount(2)
87        self.reset_headers()
88
89        # signals
90        self.cellChanged.connect(self.cell_changed)
91
92    def keyPressEvent(self, event):
93        """
94        Qt override.
95        """
96        if event.key() in [Qt.Key_Enter, Qt.Key_Return]:
97            QTableWidget.keyPressEvent(self, event)
98            # To avoid having to enter one final tab
99            self.setDisabled(True)
100            self.setDisabled(False)
101            self._parent.keyPressEvent(event)
102        else:
103            QTableWidget.keyPressEvent(self, event)
104
105    def cell_changed(self, row, col):
106        """
107        """
108        item = self.item(row, col)
109        value = None
110
111        if item:
112            rows = self.rowCount()
113            cols = self.columnCount()
114            value = item.text()
115
116        if value:
117            if row == rows - 1:
118                self.setRowCount(rows + 1)
119            if col == cols - 1:
120                self.setColumnCount(cols + 1)
121        self.reset_headers()
122
123    def reset_headers(self):
124        """
125        Update the column and row numbering in the headers.
126        """
127        rows = self.rowCount()
128        cols = self.columnCount()
129
130        for r in range(rows):
131            self.setVerticalHeaderItem(r, QTableWidgetItem(str(r)))
132        for c in range(cols):
133            self.setHorizontalHeaderItem(c, QTableWidgetItem(str(c)))
134            self.setColumnWidth(c, 40)
135
136    def text(self):
137        """
138        Return the entered array in a parseable form.
139        """
140        text = []
141        rows = self.rowCount()
142        cols = self.columnCount()
143
144        # handle empty table case
145        if rows == 2 and cols == 2:
146            item = self.item(0, 0)
147            if item is None:
148                return ''
149
150        for r in range(rows - 1):
151            for c in range(cols - 1):
152                item = self.item(r, c)
153                if item is not None:
154                    value = item.text()
155                else:
156                    value = '0'
157
158                if not value.strip():
159                    value = '0'
160
161                text.append(' ')
162                text.append(value)
163            text.append(ROW_SEPARATOR)
164
165        return ''.join(text[:-1])  # to remove the final uneeded ;
166
167
168class NumpyArrayDialog(QDialog):
169    def __init__(self, parent=None, inline=True, offset=0, force_float=False):
170        QDialog.__init__(self, parent=parent)
171        self._parent = parent
172        self._text = None
173        self._valid = None
174        self._offset = offset
175
176        # TODO: add this as an option in the General Preferences?
177        self._force_float = force_float
178
179        self._help_inline = _("""
180           <b>Numpy Array/Matrix Helper</b><br>
181           Type an array in Matlab    : <code>[1 2;3 4]</code><br>
182           or Spyder simplified syntax : <code>1 2;3 4</code>
183           <br><br>
184           Hit 'Enter' for array or 'Ctrl+Enter' for matrix.
185           <br><br>
186           <b>Hint:</b><br>
187           Use two spaces or two tabs to generate a ';'.
188           """)
189
190        self._help_table = _("""
191           <b>Numpy Array/Matrix Helper</b><br>
192           Enter an array in the table. <br>
193           Use Tab to move between cells.
194           <br><br>
195           Hit 'Enter' for array or 'Ctrl+Enter' for matrix.
196           <br><br>
197           <b>Hint:</b><br>
198           Use two tabs at the end of a row to move to the next row.
199           """)
200
201        # Widgets
202        self._button_warning = QToolButton()
203        self._button_help = HelperToolButton()
204        self._button_help.setIcon(ima.icon('MessageBoxInformation'))
205
206        style = """
207            QToolButton {
208              border: 1px solid grey;
209              padding:0px;
210              border-radius: 2px;
211              background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,
212                  stop: 0 #f6f7fa, stop: 1 #dadbde);
213            }
214            """
215        self._button_help.setStyleSheet(style)
216
217        if inline:
218            self._button_help.setToolTip(self._help_inline)
219            self._text = NumpyArrayInline(self)
220            self._widget = self._text
221        else:
222            self._button_help.setToolTip(self._help_table)
223            self._table = NumpyArrayTable(self)
224            self._widget = self._table
225
226        style = """
227            QDialog {
228              margin:0px;
229              border: 1px solid grey;
230              padding:0px;
231              border-radius: 2px;
232            }"""
233        self.setStyleSheet(style)
234
235        style = """
236            QToolButton {
237              margin:1px;
238              border: 0px solid grey;
239              padding:0px;
240              border-radius: 0px;
241            }"""
242        self._button_warning.setStyleSheet(style)
243
244        # widget setup
245        self.setWindowFlags(Qt.Window | Qt.Dialog | Qt.FramelessWindowHint)
246        self.setModal(True)
247        self.setWindowOpacity(0.90)
248        self._widget.setMinimumWidth(200)
249
250        # layout
251        self._layout = QHBoxLayout()
252        self._layout.addWidget(self._widget)
253        self._layout.addWidget(self._button_warning, 1, Qt.AlignTop)
254        self._layout.addWidget(self._button_help, 1, Qt.AlignTop)
255        self.setLayout(self._layout)
256
257        self._widget.setFocus()
258
259    def keyPressEvent(self, event):
260        """
261        Qt override.
262        """
263        QToolTip.hideText()
264        ctrl = event.modifiers() & Qt.ControlModifier
265
266        if event.key() in [Qt.Key_Enter, Qt.Key_Return]:
267            if ctrl:
268                self.process_text(array=False)
269            else:
270                self.process_text(array=True)
271            self.accept()
272        else:
273            QDialog.keyPressEvent(self, event)
274
275    def event(self, event):
276        """
277        Qt Override.
278
279        Usefull when in line edit mode.
280        """
281        if event.type() == QEvent.KeyPress and event.key() == Qt.Key_Tab:
282            return False
283        return QWidget.event(self, event)
284
285    def process_text(self, array=True):
286        """
287        Construct the text based on the entered content in the widget.
288        """
289        if array:
290            prefix = 'np.array([['
291        else:
292            prefix = 'np.matrix([['
293
294        suffix = ']])'
295        values = self._widget.text().strip()
296
297        if values != '':
298            # cleans repeated spaces
299            exp = r'(\s*)' + ROW_SEPARATOR + r'(\s*)'
300            values = re.sub(exp, ROW_SEPARATOR, values)
301            values = re.sub(r"\s+", " ", values)
302            values = re.sub(r"]$", "", values)
303            values = re.sub(r"^\[", "", values)
304            values = re.sub(ROW_SEPARATOR + r'*$', '', values)
305
306            # replaces spaces by commas
307            values = values.replace(' ',  ELEMENT_SEPARATOR)
308
309            # iterate to find number of rows and columns
310            new_values = []
311            rows = values.split(ROW_SEPARATOR)
312            nrows = len(rows)
313            ncols = []
314            for row in rows:
315                new_row = []
316                elements = row.split(ELEMENT_SEPARATOR)
317                ncols.append(len(elements))
318                for e in elements:
319                    num = e
320
321                    # replaces not defined values
322                    if num in NAN_VALUES:
323                        num = 'np.nan'
324
325                    # Convert numbers to floating point
326                    if self._force_float:
327                        try:
328                            num = str(float(e))
329                        except:
330                            pass
331                    new_row.append(num)
332                new_values.append(ELEMENT_SEPARATOR.join(new_row))
333            new_values = ROW_SEPARATOR.join(new_values)
334            values = new_values
335
336            # Check validity
337            if len(set(ncols)) == 1:
338                self._valid = True
339            else:
340                self._valid = False
341
342            # Single rows are parsed as 1D arrays/matrices
343            if nrows == 1:
344                prefix = prefix[:-1]
345                suffix = suffix.replace("]])", "])")
346
347            # Fix offset
348            offset = self._offset
349            braces = BRACES.replace(' ', '\n' + ' '*(offset + len(prefix) - 1))
350
351            values = values.replace(ROW_SEPARATOR,  braces)
352            text = "{0}{1}{2}".format(prefix, values, suffix)
353
354            self._text = text
355        else:
356            self._text = ''
357        self.update_warning()
358
359    def update_warning(self):
360        """
361        Updates the icon and tip based on the validity of the array content.
362        """
363        widget = self._button_warning
364        if not self.is_valid():
365            tip = _('Array dimensions not valid')
366            widget.setIcon(ima.icon('MessageBoxWarning'))
367            widget.setToolTip(tip)
368            QToolTip.showText(self._widget.mapToGlobal(QPoint(0, 5)), tip)
369        else:
370            self._button_warning.setToolTip('')
371
372    def is_valid(self):
373        """
374        Return if the current array state is valid.
375        """
376        return self._valid
377
378    def text(self):
379        """
380        Return the parsed array/matrix text.
381        """
382        return self._text
383
384    @property
385    def array_widget(self):
386        """
387        Return the array builder widget.
388        """
389        return self._widget
390
391
392def test():  # pragma: no cover
393    from spyder.utils.qthelpers import qapplication
394    app = qapplication()
395    dlg_table = NumpyArrayDialog(None, inline=False)
396    dlg_inline = NumpyArrayDialog(None, inline=True)
397    dlg_table.show()
398    dlg_inline.show()
399    app.exec_()
400
401
402if __name__ == "__main__":  # pragma: no cover
403    test()
404