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