Aluode/PerceptionLabPortable
0
1import keyword2import os3import pkgutil4import re5import subprocess6import sys7from argparse import Namespace8from collections import OrderedDict9from functools import lru_cache10from typing import Optional11 12import pyqtgraph as pg13from pyqtgraph.Qt import QT_LIB, QtCore, QtGui, QtWidgets14 15app = pg.mkQApp()16 17 18path = os.path.abspath(os.path.dirname(__file__))19sys.path.insert(0, path)20 21import exampleLoaderTemplate_generic as ui_template22import utils23 24# based on https://github.com/art1415926535/PyQt5-syntax-highlighting25 26QRegularExpression = QtCore.QRegularExpression27 28QFont = QtGui.QFont29QColor = QtGui.QColor30QTextCharFormat = QtGui.QTextCharFormat31QSyntaxHighlighter = QtGui.QSyntaxHighlighter32 33 34def charFormat(color, style='', background=None):35 """36 Return a QTextCharFormat with the given attributes.37 """38 _color = pg.functions.mkColor(color)39 40 _format = QTextCharFormat()41 _format.setForeground(_color)42 if 'bold' in style:43 _format.setFontWeight(QFont.Weight.Bold)44 if 'italic' in style:45 _format.setFontItalic(True)46 if background is not None:47 _format.setBackground(pg.mkColor(background))48 49 return _format50 51 52class LightThemeColors:53 54 Red = "#B71C1C"55 Pink = "#FCE4EC"56 Purple = "#4A148C"57 DeepPurple = "#311B92"58 Indigo = "#1A237E"59 Blue = "#0D47A1"60 LightBlue = "#01579B"61 Cyan = "#006064"62 Teal = "#004D40"63 Green = "#1B5E20"64 LightGreen = "#33691E"65 Lime = "#827717"66 Yellow = "#F57F17"67 Amber = "#FF6F00"68 Orange = "#E65100"69 DeepOrange = "#BF360C"70 Brown = "#3E2723"71 Grey = "#212121"72 BlueGrey = "#263238"73 74 75class DarkThemeColors:76 77 Red = "#F44336"78 Pink = "#F48FB1"79 Purple = "#CE93D8"80 DeepPurple = "#B39DDB"81 Indigo = "#9FA8DA"82 Blue = "#90CAF9"83 LightBlue = "#81D4FA"84 Cyan = "#80DEEA"85 Teal = "#80CBC4"86 Green = "#A5D6A7"87 LightGreen = "#C5E1A5"88 Lime = "#E6EE9C"89 Yellow = "#FFF59D"90 Amber = "#FFE082"91 Orange = "#FFCC80"92 DeepOrange = "#FFAB91"93 Brown = "#BCAAA4"94 Grey = "#EEEEEE"95 BlueGrey = "#B0BEC5"96 97 98LIGHT_STYLES = {99 'keyword': charFormat(LightThemeColors.Blue, 'bold'),100 'operator': charFormat(LightThemeColors.Red, 'bold'),101 'brace': charFormat(LightThemeColors.Purple),102 'defclass': charFormat(LightThemeColors.Indigo, 'bold'),103 'string': charFormat(LightThemeColors.Amber),104 'string2': charFormat(LightThemeColors.DeepPurple),105 'comment': charFormat(LightThemeColors.Green, 'italic'),106 'self': charFormat(LightThemeColors.Blue, 'bold'),107 'numbers': charFormat(LightThemeColors.Teal),108}109 110 111DARK_STYLES = {112 'keyword': charFormat(DarkThemeColors.Blue, 'bold'),113 'operator': charFormat(DarkThemeColors.Red, 'bold'),114 'brace': charFormat(DarkThemeColors.Purple),115 'defclass': charFormat(DarkThemeColors.Indigo, 'bold'),116 'string': charFormat(DarkThemeColors.Amber),117 'string2': charFormat(DarkThemeColors.DeepPurple),118 'comment': charFormat(DarkThemeColors.Green, 'italic'),119 'self': charFormat(DarkThemeColors.Blue, 'bold'),120 'numbers': charFormat(DarkThemeColors.Teal),121}122 123 124class PythonHighlighter(QSyntaxHighlighter):125 """Syntax highlighter for the Python language.126 """127 # Python keywords128 keywords = keyword.kwlist129 130 # Python operators131 operators = [132 r'=',133 # Comparison134 r'==', r'!=', r'<', r'<=', r'>', r'>=',135 # Arithmetic136 r'\+', r"-", r'\*', r'/', r'//', r'%', r'\*\*',137 # In-place138 r'\+=', r'-=', r'\*=', r'/=', r'\%=',139 # Bitwise140 r'\^', r'\|', r'&', r'~', r'>>', r'<<',141 ]142 143 # Python braces144 braces = [145 r'\{', r'\}', r'\(', r'\)', r'\[', r'\]',146 ]147 148 def __init__(self, document):149 super().__init__(document)150 151 # Multi-line strings (expression, flag, style)152 self.tri_single = (QRegularExpression("'''"), 1, 'string2')153 self.tri_double = (QRegularExpression('"""'), 2, 'string2')154 155 rules = []156 157 # Keyword, operator, and brace rules158 rules += [(r'\b%s\b' % w, 0, 'keyword')159 for w in PythonHighlighter.keywords]160 rules += [(o, 0, 'operator')161 for o in PythonHighlighter.operators]162 rules += [(b, 0, 'brace')163 for b in PythonHighlighter.braces]164 165 # All other rules166 rules += [167 # 'self'168 (r'\bself\b', 0, 'self'),169 170 # 'def' followed by an identifier171 (r'\bdef\b\s*(\w+)', 1, 'defclass'),172 # 'class' followed by an identifier173 (r'\bclass\b\s*(\w+)', 1, 'defclass'),174 175 # Numeric literals176 (r'\b[+-]?[0-9]+[lL]?\b', 0, 'numbers'),177 (r'\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b', 0, 'numbers'),178 (r'\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b', 0, 'numbers'),179 180 # Double-quoted string, possibly containing escape sequences181 (r'"[^"\\]*(\\.[^"\\]*)*"', 0, 'string'),182 # Single-quoted string, possibly containing escape sequences183 (r"'[^'\\]*(\\.[^'\\]*)*'", 0, 'string'),184 185 # From '#' until a newline186 (r'#[^\n]*', 0, 'comment'),187 ]188 self.rules = rules189 self.searchText = None190 191 @property192 def styles(self):193 app = QtWidgets.QApplication.instance()194 return DARK_STYLES if app.property('darkMode') else LIGHT_STYLES195 196 def highlightBlock(self, text):197 """Apply syntax highlighting to the given block of text.198 """199 # Do other syntax formatting200 rules = self.rules.copy()201 for expression, nth, format in rules:202 format = self.styles[format]203 204 for n, match in enumerate(re.finditer(expression, text)):205 if n < nth:206 continue207 start = match.start()208 length = match.end() - start209 self.setFormat(start, length, format)210 211 self.applySearchHighlight(text)212 self.setCurrentBlockState(0)213 214 # Do multi-line strings215 in_multiline = self.match_multiline(text, *self.tri_single)216 if not in_multiline:217 in_multiline = self.match_multiline(text, *self.tri_double)218 219 def match_multiline(self, text, delimiter, in_state, style):220 """Do highlighting of multi-line strings. 221 222 =========== ==========================================================223 delimiter (QRegularExpression) for triple-single-quotes or 224 triple-double-quotes225 in_state (int) to represent the corresponding state changes when 226 inside those strings. Returns True if we're still inside a227 multi-line string when this function is finished.228 style (str) representation of the kind of style to use229 =========== ==========================================================230 """231 # If inside triple-single quotes, start at 0232 if self.previousBlockState() == in_state:233 start = 0234 add = 0235 # Otherwise, look for the delimiter on this line236 else:237 match = delimiter.match(text)238 start = match.capturedStart()239 # Move past this match240 add = match.capturedLength()241 242 # As long as there's a delimiter match on this line...243 while start >= 0:244 # Look for the ending delimiter245 match = delimiter.match(text, start + add)246 end = match.capturedEnd()247 # Ending delimiter on this line?248 if end >= add:249 length = end - start + add + match.capturedLength()250 self.setCurrentBlockState(0)251 # No; multi-line string252 else:253 self.setCurrentBlockState(in_state)254 length = len(text) - start + add255 # Apply formatting256 self.setFormat(start, length, self.styles[style])257 # Highlighting sits on top of this formatting258 # Look for the next match259 match = delimiter.match(text, start + length)260 start = match.capturedStart()261 262 self.applySearchHighlight(text)263 264 # Return True if still inside a multi-line string, False otherwise265 if self.currentBlockState() == in_state:266 return True267 else:268 return False269 270 def applySearchHighlight(self, text):271 if not self.searchText:272 return273 expr = f'(?i){self.searchText}'274 palette: QtGui.QPalette = app.palette()275 color = palette.highlight().color()276 fgndColor = palette.color(palette.ColorGroup.Current,277 palette.ColorRole.Text).name()278 style = charFormat(fgndColor, background=color.name())279 for match in re.finditer(expr, text):280 start = match.start()281 length = match.end() - start282 self.setFormat(start, length, style)283 284 285def unnestedDict(exDict):286 """Converts a dict-of-dicts to a singly nested dict for non-recursive parsing"""287 out = {}288 for kk, vv in exDict.items():289 if isinstance(vv, dict):290 out.update(unnestedDict(vv))291 else:292 out[kk] = vv293 return out294 295 296 297class ExampleLoader(QtWidgets.QMainWindow):298 # update qtLibCombo item order to match bindings in the UI file and recreate299 # the templates files if you change bindings.300 bindings = {'PyQt6': 0, 'PySide6': 1, 'PyQt5': 2, 'PySide2': 3}301 modules = tuple(m.name for m in pkgutil.iter_modules())302 def __init__(self):303 QtWidgets.QMainWindow.__init__(self)304 self.ui = ui_template.Ui_Form()305 self.cw = QtWidgets.QWidget()306 self.setCentralWidget(self.cw)307 self.ui.setupUi(self.cw)308 self.setWindowTitle("PyQtGraph Examples")309 self.codeBtn = QtWidgets.QPushButton('Run Edited Code')310 self.codeLayout = QtWidgets.QGridLayout()311 self.ui.codeView.setLayout(self.codeLayout)312 self.hl = PythonHighlighter(self.ui.codeView.document())313 app = QtWidgets.QApplication.instance()314 policy = QtWidgets.QSizePolicy.Policy.Expanding315 self.codeLayout.addItem(QtWidgets.QSpacerItem(100,100, policy, policy), 0, 0)316 self.codeLayout.addWidget(self.codeBtn, 1, 1)317 self.codeBtn.hide()318 319 textFil = self.ui.exampleFilter320 self.curListener = None321 self.ui.exampleFilter.setFocus()322 self.ui.qtLibCombo.addItems(self.bindings.keys())323 self.ui.qtLibCombo.setCurrentIndex(self.bindings[QT_LIB])324 325 326 def onComboChanged(searchType):327 if self.curListener is not None:328 self.curListener.disconnect()329 self.curListener = textFil.textChanged330 # In case the regex was invalid before switching to title search,331 # ensure the "invalid" color is reset332 self.ui.exampleFilter.setStyleSheet('')333 if searchType == 'Content Search':334 self.curListener.connect(self.filterByContent)335 else:336 self.hl.searchText = None337 self.curListener.connect(self.filterByTitle)338 # Fire on current text, too339 self.curListener.emit(textFil.text())340 341 self.ui.searchFiles.currentTextChanged.connect(onComboChanged)342 onComboChanged(self.ui.searchFiles.currentText())343 344 self.itemCache = []345 self.populateTree(self.ui.exampleTree.invisibleRootItem(), utils.examples_)346 self.ui.exampleTree.expandAll()347 348 self.resize(1000,500)349 self.show()350 self.ui.splitter.setSizes([250,750])351 352 self.oldText = self.ui.codeView.toPlainText()353 self.ui.loadBtn.clicked.connect(self.loadFile)354 self.ui.exampleTree.currentItemChanged.connect(self.showFile)355 self.ui.exampleTree.itemDoubleClicked.connect(self.loadFile)356 self.ui.codeView.textChanged.connect(self.onTextChange)357 self.codeBtn.clicked.connect(self.runEditedCode)358 self.updateCodeViewTabWidth(self.ui.codeView.font())359 360 def event(self, event: Optional[QtCore.QEvent]):361 if event is None:362 return super().event(None)363 if event.type() in [364 QtCore.QEvent.Type.ApplicationPaletteChange,365 ]:366 app = pg.mkQApp()367 try:368 darkMode = app.styleHints().colorScheme() == QtCore.Qt.ColorScheme.Dark369 except AttributeError:370 palette = app.palette()371 windowTextLightness = palette.color(QtGui.QPalette.ColorRole.WindowText).lightness()372 windowLightness = palette.color(QtGui.QPalette.ColorRole.Window).lightness()373 darkMode = windowTextLightness > windowLightness374 app.setProperty('darkMode', darkMode)375 self.hl = PythonHighlighter(self.ui.codeView.document())376 return super().event(event)377 378 def updateCodeViewTabWidth(self,font):379 """380 Change the codeView tabStopDistance to 4 spaces based on the size of the current font381 """382 fm = QtGui.QFontMetrics(font)383 tabWidth = fm.horizontalAdvance(' ' * 4)384 # the default value is 80 pixels! that's more than 2x what we want.385 self.ui.codeView.setTabStopDistance(tabWidth)386 387 def showEvent(self, event) -> None:388 super(ExampleLoader, self).showEvent(event)389 disabledColor = QColor(QtCore.Qt.GlobalColor.red)390 for name, idx in self.bindings.items():391 disableBinding = name not in self.modules392 if disableBinding:393 item = self.ui.qtLibCombo.model().item(idx)394 item.setData(disabledColor, QtCore.Qt.ItemDataRole.ForegroundRole)395 item.setEnabled(False)396 item.setToolTip(f'{item.text()} is not installed')397 398 def onTextChange(self):399 """400 textChanged fires when the highlighter is reassigned the same document.401 Prevent this from showing "run edited code" by checking for actual402 content change403 """404 newText = self.ui.codeView.toPlainText()405 if newText != self.oldText:406 self.oldText = newText407 self.codeEdited() 408 409 def filterByTitle(self, text):410 self.showExamplesByTitle(self.getMatchingTitles(text))411 self.hl.setDocument(self.ui.codeView.document())412 413 def filterByContent(self, text=None):414 # If the new text isn't valid regex, fail early and highlight the search filter red to indicate a problem415 # to the user416 validRegex = True417 try:418 re.compile(text)419 self.ui.exampleFilter.setStyleSheet('')420 except re.error:421 colors = DarkThemeColors if app.property('darkMode') else LightThemeColors422 errorColor = pg.mkColor(colors.Red)423 validRegex = False424 errorColor.setAlpha(100)425 # Tuple prints nicely :)426 self.ui.exampleFilter.setStyleSheet(f'background: rgba{errorColor.getRgb()}')427 if not validRegex:428 return429 checkDict = unnestedDict(utils.examples_)430 self.hl.searchText = text431 # Need to reapply to current document432 self.hl.setDocument(self.ui.codeView.document())433 titles = []434 text = text.lower()435 for kk, vv in checkDict.items():436 if isinstance(vv, Namespace):437 vv = vv.filename438 filename = os.path.join(path, vv)439 contents = self.getExampleContent(filename).lower()440 if text in contents:441 titles.append(kk)442 self.showExamplesByTitle(titles)443 444 def getMatchingTitles(self, text, exDict=None, acceptAll=False):445 if exDict is None:446 exDict = utils.examples_447 text = text.lower()448 titles = []449 for kk, vv in exDict.items():450 matched = acceptAll or text in kk.lower()451 if isinstance(vv, dict):452 titles.extend(self.getMatchingTitles(text, vv, acceptAll=matched))453 elif matched:454 titles.append(kk)455 return titles456 457 def showExamplesByTitle(self, titles):458 QTWI = QtWidgets.QTreeWidgetItemIterator459 flag = QTWI.IteratorFlag.NoChildren460 treeIter = QTWI(self.ui.exampleTree, flag)461 item = treeIter.value()462 while item is not None:463 parent = item.parent()464 show = (item.childCount() or item.text(0) in titles)465 item.setHidden(not show)466 467 # If all children of a parent are gone, hide it468 if parent:469 hideParent = True470 for ii in range(parent.childCount()):471 if not parent.child(ii).isHidden():472 hideParent = False473 break474 parent.setHidden(hideParent)475 476 treeIter += 1477 item = treeIter.value()478 479 def simulate_black_mode(self):480 """481 used to simulate MacOS "black mode" on other platforms482 intended for debug only, as it manage only the QPlainTextEdit483 """484 # first, a dark background485 c = QtGui.QColor('#171717')486 p = self.ui.codeView.palette()487 p.setColor(QtGui.QPalette.ColorGroup.Active, QtGui.QPalette.ColorRole.Base, c)488 p.setColor(QtGui.QPalette.ColorGroup.Inactive, QtGui.QPalette.ColorRole.Base, c)489 self.ui.codeView.setPalette(p)490 # then, a light font491 f = QtGui.QTextCharFormat()492 f.setForeground(QtGui.QColor('white'))493 self.ui.codeView.setCurrentCharFormat(f)494 # finally, override application automatic detection495 app = QtWidgets.QApplication.instance()496 app.setProperty('darkMode', True)497 498 def populateTree(self, root, examples):499 bold_font = None500 for key, val in examples.items():501 item = QtWidgets.QTreeWidgetItem([key])502 self.itemCache.append(item) # PyQt 4.9.6 no longer keeps references to these wrappers,503 # so we need to make an explicit reference or else the .file504 # attribute will disappear.505 if isinstance(val, OrderedDict):506 self.populateTree(item, val)507 elif isinstance(val, Namespace):508 item.file = val.filename509 if 'recommended' in val:510 if bold_font is None:511 bold_font = item.font(0)512 bold_font.setBold(True)513 item.setFont(0, bold_font)514 else:515 item.file = val516 root.addChild(item)517 518 def currentFile(self):519 item = self.ui.exampleTree.currentItem()520 if hasattr(item, 'file'):521 return os.path.join(path, item.file)522 return None523 524 def loadFile(self, *, edited=False):525 # make *edited* keyword-only so it is not confused for extra arguments526 # sent by ui signals527 qtLib = self.ui.qtLibCombo.currentText()528 env = dict(os.environ, PYQTGRAPH_QT_LIB=qtLib)529 example_path = os.path.abspath(os.path.dirname(__file__))530 path = os.path.dirname(os.path.dirname(example_path))531 env['PYTHONPATH'] = f'{path}'532 if edited:533 proc = subprocess.Popen([sys.executable, '-'], stdin=subprocess.PIPE, cwd=example_path, env=env)534 code = self.ui.codeView.toPlainText().encode('UTF-8')535 proc.stdin.write(code)536 proc.stdin.close()537 else:538 fn = self.currentFile()539 if fn is None:540 return541 subprocess.Popen([sys.executable, fn], cwd=path, env=env)542 543 def showFile(self):544 fn = self.currentFile()545 text = self.getExampleContent(fn)546 self.ui.codeView.setPlainText(text)547 self.ui.loadedFileLabel.setText(fn)548 self.codeBtn.hide()549 550 @lru_cache(100)551 def getExampleContent(self, filename):552 if filename is None:553 self.ui.codeView.clear()554 return555 if os.path.isdir(filename):556 filename = os.path.join(filename, '__main__.py')557 with open(filename, "r") as currentFile:558 text = currentFile.read()559 return text560 561 def codeEdited(self):562 self.codeBtn.show()563 564 def runEditedCode(self):565 self.loadFile(edited=True)566 567 def keyPressEvent(self, event):568 super().keyPressEvent(event)569 if not (event.modifiers() & QtCore.Qt.KeyboardModifier.ControlModifier):570 return571 key = event.key()572 Key = QtCore.Qt.Key573 574 # Allow quick navigate to search575 if key == Key.Key_F:576 self.ui.exampleFilter.setFocus()577 event.accept()578 return579 580 if key not in [Key.Key_Plus, Key.Key_Minus, Key.Key_Underscore, Key.Key_Equal, Key.Key_0]:581 return582 font = self.ui.codeView.font()583 oldSize = font.pointSize()584 if key == Key.Key_Plus or key == Key.Key_Equal:585 font.setPointSize(oldSize + max(oldSize*.15, 1))586 elif key == Key.Key_Minus or key == Key.Key_Underscore:587 newSize = oldSize - max(oldSize*.15, 1)588 font.setPointSize(max(newSize, 1))589 elif key == Key.Key_0:590 # Reset to original size591 font.setPointSize(10)592 self.ui.codeView.setFont(font)593 self.updateCodeViewTabWidth(font)594 event.accept()595 596def main():597 app = pg.mkQApp()598 loader = ExampleLoader()599 loader.ui.exampleTree.setCurrentIndex(600 loader.ui.exampleTree.model().index(0,0)601 )602 pg.exec()603 604if __name__ == '__main__':605 main()606 