# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) """Array Builder Widget.""" # TODO: # - Set font based on caller? editor console? and adjust size of widget # - Fix positioning # - Use the same font as editor/console? # - Generalize separators # - Generalize API for registering new array builders # Standard library imports from __future__ import division import re # Third party imports from qtpy.QtCore import QEvent, QPoint, Qt from qtpy.QtWidgets import (QDialog, QHBoxLayout, QLineEdit, QTableWidget, QTableWidgetItem, QToolButton, QToolTip) # Local imports from spyder.config.base import _ from spyder.utils.icon_manager import ima from spyder.utils.palette import QStylePalette from spyder.widgets.helperwidgets import HelperToolButton # Constants SHORTCUT_TABLE = "Ctrl+M" SHORTCUT_INLINE = "Ctrl+Alt+M" class ArrayBuilderType: LANGUAGE = None ELEMENT_SEPARATOR = None ROW_SEPARATOR = None BRACES = None EXTRA_VALUES = None ARRAY_PREFIX = None MATRIX_PREFIX = None def check_values(self): pass class ArrayBuilderPython(ArrayBuilderType): ELEMENT_SEPARATOR = ', ' ROW_SEPARATOR = ';' BRACES = '], [' EXTRA_VALUES = { 'np.nan': ['nan', 'NAN', 'NaN', 'Na', 'NA', 'na'], 'np.inf': ['inf', 'INF'], } ARRAY_PREFIX = 'np.array([[' MATRIX_PREFIX = 'np.matrix([[' _REGISTERED_ARRAY_BUILDERS = { 'python': ArrayBuilderPython, } class ArrayInline(QLineEdit): def __init__(self, parent, options=None): super(ArrayInline, self).__init__(parent) self._parent = parent self._options = options def keyPressEvent(self, event): """Override Qt method.""" if event.key() in [Qt.Key_Enter, Qt.Key_Return]: self._parent.process_text() if self._parent.is_valid(): self._parent.keyPressEvent(event) else: super(ArrayInline, self).keyPressEvent(event) # To catch the Tab key event def event(self, event): """ Override Qt method. This is needed to be able to intercept the Tab key press event. """ if event.type() == QEvent.KeyPress: if (event.key() == Qt.Key_Tab or event.key() == Qt.Key_Space): text = self.text() cursor = self.cursorPosition() # Fix to include in "undo/redo" history if cursor != 0 and text[cursor-1] == ' ': text = (text[:cursor-1] + self._options.ROW_SEPARATOR + ' ' + text[cursor:]) else: text = text[:cursor] + ' ' + text[cursor:] self.setCursorPosition(cursor) self.setText(text) self.setCursorPosition(cursor + 1) return False return super(ArrayInline, self).event(event) class ArrayTable(QTableWidget): def __init__(self, parent, options=None): super(ArrayTable, self).__init__(parent) self._parent = parent self._options = options self.setRowCount(2) self.setColumnCount(2) self.reset_headers() # signals self.cellChanged.connect(self.cell_changed) def keyPressEvent(self, event): """Override Qt method.""" super(ArrayTable, self).keyPressEvent(event) if event.key() in [Qt.Key_Enter, Qt.Key_Return]: # To avoid having to enter one final tab self.setDisabled(True) self.setDisabled(False) self._parent.keyPressEvent(event) def cell_changed(self, row, col): item = self.item(row, col) value = None if item: rows = self.rowCount() cols = self.columnCount() value = item.text() if value: if row == rows - 1: self.setRowCount(rows + 1) if col == cols - 1: self.setColumnCount(cols + 1) self.reset_headers() def reset_headers(self): """Update the column and row numbering in the headers.""" rows = self.rowCount() cols = self.columnCount() for r in range(rows): self.setVerticalHeaderItem(r, QTableWidgetItem(str(r))) for c in range(cols): self.setHorizontalHeaderItem(c, QTableWidgetItem(str(c))) self.setColumnWidth(c, 40) def text(self): """Return the entered array in a parseable form.""" text = [] rows = self.rowCount() cols = self.columnCount() # handle empty table case if rows == 2 and cols == 2: item = self.item(0, 0) if item is None: return '' for r in range(rows - 1): for c in range(cols - 1): item = self.item(r, c) if item is not None: value = item.text() else: value = '0' if not value.strip(): value = '0' text.append(' ') text.append(value) text.append(self._options.ROW_SEPARATOR) return ''.join(text[:-1]) # Remove the final uneeded `;` class ArrayBuilderDialog(QDialog): def __init__(self, parent=None, inline=True, offset=0, force_float=False, language='python'): super(ArrayBuilderDialog, self).__init__(parent=parent) self._language = language self._options = _REGISTERED_ARRAY_BUILDERS.get('python', None) self._parent = parent self._text = None self._valid = None self._offset = offset # TODO: add this as an option in the General Preferences? self._force_float = force_float self._help_inline = _(""" Numpy Array/Matrix Helper
Type an array in Matlab : [1 2;3 4]
or Spyder simplified syntax : 1 2;3 4

Hit 'Enter' for array or 'Ctrl+Enter' for matrix.

Hint:
Use two spaces or two tabs to generate a ';'. """) self._help_table = _(""" Numpy Array/Matrix Helper
Enter an array in the table.
Use Tab to move between cells.

Hit 'Enter' for array or 'Ctrl+Enter' for matrix.

Hint:
Use two tabs at the end of a row to move to the next row. """) # Widgets self._button_warning = QToolButton() self._button_help = HelperToolButton() self._button_help.setIcon(ima.icon('MessageBoxInformation')) style = ((""" QToolButton {{ border: 1px solid grey; padding:0px; border-radius: 2px; background-color: qlineargradient(x1: 1, y1: 1, x2: 1, y2: 1, stop: 0 {stop_0}, stop: 1 {stop_1}); }} """).format(stop_0=QStylePalette.COLOR_BACKGROUND_4, stop_1=QStylePalette.COLOR_BACKGROUND_2)) self._button_help.setStyleSheet(style) if inline: self._button_help.setToolTip(self._help_inline) self._text = ArrayInline(self, options=self._options) self._widget = self._text else: self._button_help.setToolTip(self._help_table) self._table = ArrayTable(self, options=self._options) self._widget = self._table style = """ QDialog { margin:0px; border: 1px solid grey; padding:0px; border-radius: 2px; }""" self.setStyleSheet(style) style = """ QToolButton { margin:1px; border: 0px solid grey; padding:0px; border-radius: 0px; }""" self._button_warning.setStyleSheet(style) # widget setup self.setWindowFlags(Qt.Window | Qt.Dialog | Qt.FramelessWindowHint) self.setModal(True) self.setWindowOpacity(0.90) self._widget.setMinimumWidth(200) # layout self._layout = QHBoxLayout() self._layout.addWidget(self._widget) self._layout.addWidget(self._button_warning, 1, Qt.AlignTop) self._layout.addWidget(self._button_help, 1, Qt.AlignTop) self.setLayout(self._layout) self._widget.setFocus() def keyPressEvent(self, event): """Override Qt method.""" QToolTip.hideText() ctrl = event.modifiers() & Qt.ControlModifier if event.key() in [Qt.Key_Enter, Qt.Key_Return]: if ctrl: self.process_text(array=False) else: self.process_text(array=True) self.accept() else: super(ArrayBuilderDialog, self).keyPressEvent(event) def event(self, event): """ Override Qt method. Useful when in line edit mode. """ if event.type() == QEvent.KeyPress and event.key() == Qt.Key_Tab: return False return super(ArrayBuilderDialog, self).event(event) def process_text(self, array=True): """ Construct the text based on the entered content in the widget. """ if array: prefix = self._options.ARRAY_PREFIX else: prefix = self._options.MATRIX_PREFIX suffix = ']])' values = self._widget.text().strip() if values != '': # cleans repeated spaces exp = r'(\s*)' + self._options.ROW_SEPARATOR + r'(\s*)' values = re.sub(exp, self._options.ROW_SEPARATOR, values) values = re.sub(r"\s+", " ", values) values = re.sub(r"]$", "", values) values = re.sub(r"^\[", "", values) values = re.sub(self._options.ROW_SEPARATOR + r'*$', '', values) # replaces spaces by commas values = values.replace(' ', self._options.ELEMENT_SEPARATOR) # iterate to find number of rows and columns new_values = [] rows = values.split(self._options.ROW_SEPARATOR) nrows = len(rows) ncols = [] for row in rows: new_row = [] elements = row.split(self._options.ELEMENT_SEPARATOR) ncols.append(len(elements)) for e in elements: num = e # replaces not defined values for key, values in self._options.EXTRA_VALUES.items(): if num in values: num = key # Convert numbers to floating point if self._force_float: try: num = str(float(e)) except: pass new_row.append(num) new_values.append( self._options.ELEMENT_SEPARATOR.join(new_row)) new_values = self._options.ROW_SEPARATOR.join(new_values) values = new_values # Check validity if len(set(ncols)) == 1: self._valid = True else: self._valid = False # Single rows are parsed as 1D arrays/matrices if nrows == 1: prefix = prefix[:-1] suffix = suffix.replace("]])", "])") # Fix offset offset = self._offset braces = self._options.BRACES.replace( ' ', '\n' + ' '*(offset + len(prefix) - 1)) values = values.replace(self._options.ROW_SEPARATOR, braces) text = "{0}{1}{2}".format(prefix, values, suffix) self._text = text else: self._text = '' self.update_warning() def update_warning(self): """ Updates the icon and tip based on the validity of the array content. """ widget = self._button_warning if not self.is_valid(): tip = _('Array dimensions not valid') widget.setIcon(ima.icon('MessageBoxWarning')) widget.setToolTip(tip) QToolTip.showText(self._widget.mapToGlobal(QPoint(0, 5)), tip) else: self._button_warning.setToolTip('') def is_valid(self): """Return if the current array state is valid.""" return self._valid def text(self): """Return the parsed array/matrix text.""" return self._text @property def array_widget(self): """Return the array builder widget.""" return self._widget def test(): # pragma: no cover from spyder.utils.qthelpers import qapplication app = qapplication() dlg_table = ArrayBuilderDialog(None, inline=False) dlg_inline = ArrayBuilderDialog(None, inline=True) dlg_table.show() dlg_inline.show() app.exec_() if __name__ == "__main__": # pragma: no cover test()