class Editor(QtWidgets.QPlainTextEdit): """ Source code editor used in Myokit. Provides the signal ``find_action(str)`` which is fired everything a find action occurred with a description that can be used in an application's status bar. """ # Signal: Find action happened, update with text # Attributes: (description) find_action = QtCore.Signal(str) def __init__(self, parent=None): super(Editor, self).__init__(parent) # Apply default settings self._default_settings() # Add line number area self._line_number_area = LineNumberArea(self) self._line_number_area.update_width(0) # Add current line highlighting and bracket matching self.cursorPositionChanged.connect(self.cursor_changed) self.cursor_changed() # Find/replace dialog self._find = FindDialog(self) self._find.find_action.connect(self._find_action) # Line position self._line_offset = self.fontMetrics().width(' ' * 79) # Number of blocks in page up/down self._blocks_per_page = 1 # Last position in line, used for smart up/down buttons self._last_column = None self.textChanged.connect(self._text_has_changed) def activate_find_dialog(self): """ Activates the find/replace dialog for this editor: Shows the dialog if hidden, sets the focus to the query field and copies any current selection into the query field. """ self._find.activate() def cursor_changed(self): """ Slot: Called when the cursor position is changed """ # Highlight current line extra_selections = [] selection = QtWidgets.QTextEdit.ExtraSelection() selection.format.setBackground(COLOR_CURRENT_LINE) selection.format.setProperty(QtGui.QTextFormat.FullWidthSelection, True) selection.cursor = self.textCursor() selection.cursor.clearSelection() extra_selections.append(selection) # Bracket matching cursor = self.textCursor() if not cursor.hasSelection(): # Test if in front of or behind an opening or closing bracket pos = cursor.position() bracket = None if not cursor.atEnd(): cursor.setPosition(pos + 1, QtGui.QTextCursor.KeepAnchor) text = cursor.selectedText() if text in BRACKETS: bracket = cursor elif bracket is None and not cursor.atStart(): cursor.setPosition(pos - 1) cursor.setPosition(pos, QtGui.QTextCursor.KeepAnchor) text = cursor.selectedText() if text in BRACKETS: bracket = cursor if bracket: # Find matching partner doc = self.document() depth = 1 start = bracket.position() while depth > 0: if text in BRACKETS_CLOSE: other = doc.find(text, start - 1, QtGui.QTextDocument.FindBackward) match = doc.find(BRACKETS[text], start - 1, QtGui.QTextDocument.FindBackward) else: other = doc.find(text, start) match = doc.find(BRACKETS[text], start) if match.isNull(): break if other.isNull(): depth -= 1 start = match.position() elif text in BRACKETS_CLOSE: if other.position() < match.position(): depth -= 1 start = match.position() else: depth += 1 start = other.position() else: if match.position() < other.position(): depth -= 1 start = match.position() else: depth += 1 start = other.position() if depth == 0: # Apply formatting selection = QtWidgets.QTextEdit.ExtraSelection() selection.cursor = bracket selection.format.setBackground(COLOR_BG_BRACKET) extra_selections.append(selection) selection = QtWidgets.QTextEdit.ExtraSelection() selection.cursor = match selection.format.setBackground(COLOR_BG_BRACKET) extra_selections.append(selection) if extra_selections: self.setExtraSelections(extra_selections) def cursor_position(self): """ Returns a tuple ``(line, char)`` with the current cursor position. If a selection is made only the left position is used. Line and char counts both start at zero. """ cursor = self.textCursor() line = cursor.blockNumber() char = cursor.selectionStart() - cursor.block().position() return (line, char) def _default_settings(self): """ Applies this editor's default settings. """ # Set font self.setFont(FONT) # Set frame self.setFrameStyle(QtWidgets.QFrame.WinPanel | QtWidgets.QFrame.Sunken) # Disable wrapping self.setLineWrapMode(self.NoWrap) # Set tab width (if ever seen) to 4 spaces self.setTabStopWidth(self.fontMetrics().width(' ' * 4)) def _find_action(self, text): """ Passes on the find action signal. """ self.find_action.emit(text) def get_text(self): """ Returns the text in this editor. """ return self.toPlainText() def hide_find_dialog(self): """ Hides the find/replace dialog for this editor. """ self._find.hide() def jump_to(self, line, char): """ Jumps to the given line and row. """ block = self.document().findBlockByNumber(line) cursor = self.textCursor() cursor.setPosition(block.position() + char) self.setTextCursor(cursor) self.centerCursor() def keyPressEvent(self, event): """ Qt event: A key was pressed. """ # Get key and modifiers key = event.key() mod = event.modifiers() # Possible modifiers: # NoModifier # ShiftModifier, ControlModifier, AltModifiier # MetaModifier (i.e. super key) # KeyPadModifier (button is part of keypad) # GroupSwitchModifier (x11 thing) # Ignore the keypad modifier, we don't care! if mod & Qt.KeypadModifier: mod = mod ^ Qt.KeypadModifier # xor! # Actions per key/modifier combination if key == Qt.Key_Tab and mod == Qt.NoModifier: # Indent cursor = self.textCursor() start, end = cursor.selectionStart(), cursor.selectionEnd() if cursor.hasSelection(): # Add single tab to all lines in selection cursor.beginEditBlock() # Undo grouping doc = self.document() b = doc.findBlock(start) e = doc.findBlock(end).next() while b != e: cursor.setPosition(b.position()) cursor.insertText(TABS * SPACE) b = b.next() cursor.endEditBlock() else: # Insert spaces until next tab stop pos = cursor.positionInBlock() cursor.insertText((TABS - pos % TABS) * SPACE) elif key == Qt.Key_Backtab and mod == Qt.ShiftModifier: # Dedent all lines in selection (or single line if no selection) ''' cursor = self.textCursor() start, end = cursor.selectionStart(), cursor.selectionEnd() cursor.beginEditBlock() # Undo grouping doc = self.document() # Get blocks in selection blocks = [] b = doc.findBlock(start) while b.isValid() and b.position() <= end: blocks.append(b) b = b.next() # Dedent for b in blocks: t = b.text() p1 = b.position() p2 = p1 + min(4, len(t) - len(t.lstrip())) c = self.textCursor() c.setPosition(p1) c.setPosition(p2, QtGui.QTextCursor.KeepAnchor) c.removeSelectedText() cursor.endEditBlock() ''' # This silly method is required because of a bug in qt4/qt5 cursor = self.textCursor() start, end = cursor.selectionStart(), cursor.selectionEnd() first = self.document().findBlock(start) q = 0 new_text = [] new_start, new_end = start, end b = QtGui.QTextBlock(first) while b.isValid() and b.position() <= end: t = b.text() p = min(4, len(t) - len(t.lstrip())) new_text.append(t[p:]) if b == first: new_start -= p new_end -= p q += p b = b.next() last = b.previous() new_start = max(new_start, first.position()) new_end = max(new_end, new_start) if q > 0: # Cut text, replace with new cursor.beginEditBlock() cursor.setPosition(first.position()) cursor.setPosition(last.position() + last.length() - 1, QtGui.QTextCursor.KeepAnchor) cursor.removeSelectedText() cursor.insertText('\n'.join(new_text)) cursor.endEditBlock() # Set new cursor cursor.setPosition(new_start) cursor.setPosition(new_end, QtGui.QTextCursor.KeepAnchor) self.setTextCursor(cursor) elif key == Qt.Key_Enter or key == Qt.Key_Return: # Enter/Return with modifier is overruled here to mean nothing # This is very important as the default for shift-enter is to # start a new line within the same block (this can't happen with # copy-pasting, so it's safe to just catch it here). if mod == Qt.NoModifier: # "Smart" enter: # - If selection, selection is deleted # - Else, autoindenting is performed cursor = self.textCursor() cursor.beginEditBlock() if cursor.hasSelection(): # Replace selection with newline, cursor.removeSelectedText() cursor.insertBlock() else: # Insert new line with correct indenting b = self.document().findBlock(cursor.position()) t = b.text() i = t[:len(t) - len(t.lstrip())] i = i[:cursor.positionInBlock()] cursor.insertBlock() cursor.insertText(i) cursor.endEditBlock() # Scroll if necessary self.ensureCursorVisible() elif key == Qt.Key_Home and (mod == Qt.NoModifier or mod == Qt.ShiftModifier): # Plain home button: move to start of line # If Control is used: Jump to start of document # Ordinary home button: Jump to first column or first # non-whitespace character cursor = self.textCursor() block = cursor.block() cp = cursor.position() bp = block.position() if cp != bp: # Jump to first column newpos = bp # Smart up/down: self._last_column = 0 else: # Already at first column: Jump to first non-whitespace or # end of line if all whitespace t = block.text() indent = len(t) - len(t.lstrip()) newpos = bp + indent # Smart up/down: self._last_column = indent # If Shift is used: only move position (keep anchor, i.e. select) anchor = (QtGui.QTextCursor.KeepAnchor if mod == Qt.ShiftModifier else QtGui.QTextCursor.MoveAnchor) cursor.setPosition(newpos, anchor) self.setTextCursor(cursor) elif key == Qt.Key_Home and (mod == Qt.ControlModifier or mod == Qt.ControlModifier & Qt.ShiftModifier): # Move to start of document # If Shift is used: only move position (keep anchor, i.e. select) anchor = (QtGui.QTextCursor.KeepAnchor if mod == Qt.ShiftModifier else QtGui.QTextCursor.MoveAnchor) cursor = self.textCursor() cursor.setPosition(0, anchor) self.setTextCursor(cursor) elif key in (Qt.Key_Up, Qt.Key_Down, Qt.Key_PageUp, Qt.Key_PageDown) \ and (mod == Qt.NoModifier or mod == Qt.ShiftModifier): # Move cursor up/down # Maintain the column position, even when the current row doesn't # have as many characters. Reset this behavior as soon as a # left/right home/end action is made or whenever the text is # changed. # Set up operation anchor = (QtGui.QTextCursor.KeepAnchor if mod == Qt.ShiftModifier else QtGui.QTextCursor.MoveAnchor) operation = \ QtGui.QTextCursor.PreviousBlock if key in ( Qt.Key_Up, Qt.Key_PageUp) else QtGui.QTextCursor.NextBlock n = 1 if key in (Qt.Key_Up, Qt.Key_Down) else (self._blocks_per_page - 3) # Move cursor = self.textCursor() if self._last_column is None: # Update "smart" column self._last_column = cursor.positionInBlock() if cursor.movePosition(operation, anchor, n): column = min(cursor.block().length() - 1, self._last_column) cursor.setPosition(cursor.position() + column, anchor) else: # Up/Down beyond document start/end? Move cursor to document # start/end and update last column if operation == QtGui.QTextCursor.NextBlock: cursor.movePosition(QtGui.QTextCursor.EndOfBlock, anchor) else: cursor.movePosition(QtGui.QTextCursor.StartOfBlock, anchor) self._last_column = cursor.positionInBlock() self.setTextCursor(cursor) elif key in (Qt.Key_Left, Qt.Key_Right, Qt.Key_End) and not (mod & Qt.AltModifier): # Allow all modifiers except alt # Reset smart up/down behavior self._last_column = None # Pass to parent class super(Editor, self).keyPressEvent(event) elif key == Qt.Key_Insert and mod == Qt.NoModifier: # Insert/replace self.setOverwriteMode(not self.overwriteMode()) else: # Default keyboard shortcuts / functions: # Backspace OK # Delete OK # Control+C OK # Control+V OK # Control+X OK # Control+Insert OK # Shift+Insert OK # Shift+Delete OK # Control+Z OK # Control+Y OK # LeftArrow Overwritten (maintained) # RightArrow Overwritten (maintained) # UpArrow Overwritten (maintained) # DownArrow Overwritten (maintained) # Control+RightArrow OK (Jump to next word) # Control+LeftArrow OK (Jump to previous word) # Control+UpArrow Removed # Control+Down Arrow Removed # PageUp Overwritten (maintained) # PageDown Overwritten (maintained) # Home Overwritten (maintained) # End Overwritten (maintained) # Control+Home Overwritten (maintained) # Control+End Overwritten (maintained) # Alt+Wheel OK (Horizontal scrolling) # Control+Wheel OK (Fast scrolling) # Control+K Removed # Not listed, but very important: # Shift-Enter Starts new line within the same block! # Definitely removed # Ctrl-i Undocumented, but inserts tab... ctrl_ignore = (Qt.Key_K, Qt.Key_I) if mod == Qt.ControlModifier and key in ctrl_ignore: # Control-K: ignore pass elif key == Qt.Key_Up or key == Qt.Key_Down: # Up/down with modifiers: ignore pass else: # Let parent class handle it super(Editor, self).keyPressEvent(event) def _line_number_area_width(self): """ Returns the required width for the number area """ return 4 + self.fontMetrics().width(str(max(1, self.blockCount()))) def _line_number_area_paint(self, area, event): """ Repaints the line number area. """ # Repaint area rect = event.rect() etop = rect.top() ebot = rect.bottom() # Repaint metrics metrics = self.fontMetrics() height = metrics.height() width = area.width() # Create painter, get font metrics painter = QtGui.QPainter(area) painter.fillRect(rect, COLOR_BG_LINE_NUMBER) # Get top and bottom of first block block = self.firstVisibleBlock() geom = self.blockBoundingGeometry(block) btop = geom.translated(self.contentOffset()).top() bbot = btop + geom.height() # Iterate over blocks count = block.blockNumber() while block.isValid() and btop <= ebot: count += 1 if block.isVisible() and bbot >= etop: painter.drawText(0, btop, width, height, Qt.AlignRight, str(count)) block = block.next() btop = bbot bbot += self.blockBoundingRect(block).height() def paintEvent(self, e): """ Paints this editor. """ # Paint the editor super(Editor, self).paintEvent(e) # Paint a line between the editor and the line number area x = self.contentOffset().x() + self.document().documentMargin() \ + self._line_offset p = QtGui.QPainter(self.viewport()) p.setPen(QtGui.QPen(QtGui.QColor('#ddd'))) rect = e.rect() p.drawLine(x, rect.top(), x, rect.bottom()) def replace(self, text): """ Replaces the current text with the given text, in a single operation that does not reset undo/redo. """ self.selectAll() cursor = self.textCursor() cursor.beginEditBlock() cursor.removeSelectedText() self.appendPlainText(str(text)) cursor.endEditBlock() def resizeEvent(self, event): """ Qt event: Editor is resized. """ super(Editor, self).resizeEvent(event) # Update line number area rect = self.contentsRect() self._line_number_area.setGeometry(rect.left(), rect.top(), self._line_number_area_width(), rect.height()) # Set number of "blocks" per page font = self.fontMetrics() self._blocks_per_page = int(rect.height() / font.height()) def save_config(self, config, section): """ Saves this editor's configuration using the given :class:`ConfigParser` ``config``. Stores all settings in the section ``section``. """ config.add_section(section) # Find options: case sensitive / whole word config.set(section, 'case_sensitive', self._find.case_sensitive()) config.set(section, 'whole_word', self._find.whole_word()) def load_config(self, config, section): """ Loads this editor's configuration using the given :class:`ConfigParser` ``config``. Loads all settings from the section ``section``. """ if config.has_section(section): # Find options: case sensitive / whole word if config.has_option(section, 'case_sensitive'): self._find.set_case_sensitive( config.getboolean(section, 'case_sensitive')) if config.has_option(section, 'whole_word'): self._find.set_whole_word( config.getboolean(section, 'whole_word')) def set_cursor(self, pos): """ Changes the current cursor to the given position and scrolls so that its visible. """ cursor = self.textCursor() cursor.setPosition(pos) self.setTextCursor(cursor) self.centerCursor() def set_text(self, text): """ Replaces the text in this editor. """ if text: self.setPlainText(str(text)) else: # Bizarre workaround for bug: # https://bugreports.qt.io/browse/QTBUG-42318 self.selectAll() cursor = self.textCursor() cursor.removeSelectedText() doc = self.document() doc.clearUndoRedoStacks() doc.setModified(False) def show_find_dialog(self): """ Displays a find/replace dialog for this editor. """ self._find.show() def _text_has_changed(self): """ Called whenever the text has changed, resets the smart up/down behavior. """ self._last_column = None def toggle_comment(self): """ Comments or uncomments the selected lines """ # Comment or uncomment selected lines cursor = self.textCursor() start, end = cursor.selectionStart(), cursor.selectionEnd() doc = self.document() first, last = doc.findBlock(start), doc.findBlock(end) # Determine minimum indent and adding or removing block = first blocks = [first] while block != last: block = block.next() blocks.append(block) lines = [block.text() for block in blocks] indent = [len(t) - len(t.lstrip()) for t in lines if len(t) > 0] indent = min(indent) if indent else 0 remove = True for line in lines: if line[indent:indent + 1] != '#': remove = False break cursor.beginEditBlock() if remove: for block in blocks: p = block.position() + indent cursor.setPosition(p) cursor.setPosition(p + 1, QtGui.QTextCursor.KeepAnchor) cursor.removeSelectedText() else: for block in blocks: p = block.position() n = len(block.text()) if len(block.text()) < indent: cursor.setPosition(p) cursor.setPosition(p + n, QtGui.QTextCursor.KeepAnchor) cursor.removeSelectedText() cursor.insertText(' ' * indent + '#') else: cursor.setPosition(p + indent) cursor.insertText('#') cursor.endEditBlock() def trim_trailing_whitespace(self): """ Trims all trailing whitespace from this document. """ block = self.document().begin() cursor = self.textCursor() cursor.beginEditBlock() # Undo grouping while block.isValid(): t = block.text() a = len(t) b = len(t.rstrip()) if a > b: cursor.setPosition(block.position() + b) cursor.setPosition(block.position() + a, QtGui.QTextCursor.KeepAnchor) cursor.removeSelectedText() block = block.next() cursor.endEditBlock()
class FindDialog(QtWidgets.QDialog): """ Find/replace dialog for :class:`Editor`. """ # Signal: Find action happened, update with text # Attributes: (description) find_action = QtCore.Signal(str) def __init__(self, editor): # New style doesn't work: QtWidgets.QDialog.__init__(self, editor, Qt.Window) self.setWindowTitle('Find and replace') self._editor = editor # Fix background color of line edits self.setStyleSheet('QLineEdit{background: white;}') # Create widgets self._close_button = QtWidgets.QPushButton('Close') self._close_button.clicked.connect(self.action_close) self._replace_all_button = QtWidgets.QPushButton('Replace all') self._replace_all_button.clicked.connect(self.action_replace_all) self._replace_button = QtWidgets.QPushButton('Replace') self._replace_button.clicked.connect(self.action_replace) self._find_button = QtWidgets.QPushButton('Find') self._find_button.clicked.connect(self.action_find) self._search_label = QtWidgets.QLabel('Search for') self._search_field = QtWidgets.QLineEdit() self._replace_label = QtWidgets.QLabel('Replace with') self._replace_field = QtWidgets.QLineEdit() self._case_check = QtWidgets.QCheckBox('Case sensitive') self._whole_check = QtWidgets.QCheckBox('Match whole word only') # Create layout text_layout = QtWidgets.QGridLayout() text_layout.addWidget(self._search_label, 0, 0) text_layout.addWidget(self._search_field, 0, 1) text_layout.addWidget(self._replace_label, 1, 0) text_layout.addWidget(self._replace_field, 1, 1) check_layout = QtWidgets.QBoxLayout(QtWidgets.QBoxLayout.TopToBottom) check_layout.addWidget(self._case_check) check_layout.addWidget(self._whole_check) button_layout = QtWidgets.QGridLayout() button_layout.addWidget(self._close_button, 0, 0) button_layout.addWidget(self._replace_all_button, 0, 1) button_layout.addWidget(self._replace_button, 0, 2) button_layout.addWidget(self._find_button, 0, 3) layout = QtWidgets.QBoxLayout(QtWidgets.QBoxLayout.TopToBottom) layout.addLayout(text_layout) layout.addLayout(check_layout) layout.addLayout(button_layout) self.setLayout(layout) self._search_field.setEnabled(True) self._replace_field.setEnabled(True) def action_close(self): """ Qt slot: Close this window. """ self.close() def action_find(self): """ Qt slot: Find (next) item. """ query = self._search_field.text() if query == '': self.find_action.emit('No query set') return flags = 0x0 if self._case_check.isChecked(): flags |= QtGui.QTextDocument.FindCaseSensitively if self._whole_check.isChecked(): flags |= QtGui.QTextDocument.FindWholeWords if flags: found = self._editor.find(query, flags) else: found = self._editor.find(query) if found is False: # Not found? Try from top of document previous_cursor = self._editor.textCursor() cursor = self._editor.textCursor() cursor.setPosition(0) self._editor.setTextCursor(cursor) if flags: found = self._editor.find(query, flags) else: found = self._editor.find(query) if found is False: self._editor.setTextCursor(previous_cursor) self.find_action.emit('Query not found.') return cursor = self._editor.textCursor() line = 1 + cursor.blockNumber() char = cursor.selectionStart() - cursor.block().position() self.find_action.emit('Match found on line ' + str(line) + ' char ' + str(char) + '.') def action_replace(self): """ Qt slot: Replace found item with replacement. """ query = self._search_field.text() replacement = self._replace_field.text() if query == '': self.find_action.emit('No query set') return cursor = self._editor.textCursor() a, b = cursor.selectedText(), query if not self._case_check.isChecked(): a, b = a.lower(), b.lower() if a == b: cursor.insertText(replacement) self.action_find() def action_replace_all(self): """ Qt slot: Replace all found items with replacement """ query = self._search_field.text() replacement = self._replace_field.text() if query == '': self.find_action.emit('No query set') return flags = 0x0 if self._case_check.isChecked(): flags |= QtGui.QTextDocument.FindCaseSensitively if self._whole_check.isChecked(): flags |= QtGui.QTextDocument.FindWholeWords n = 0 found = True scrollpos = self._editor.verticalScrollBar().value() grouping = self._editor.textCursor() grouping.beginEditBlock() continue_from_top = True while found: if flags: found = self._editor.find(query, flags) else: found = self._editor.find(query) if not found and continue_from_top: # Not found? Try from top of document cursor = self._editor.textCursor() cursor.setPosition(0) self._editor.setTextCursor(cursor) if flags: found = self._editor.find(query, flags) else: found = self._editor.find(query) # Don't keep going round and round # (This can happen if you replace something with itself, or # with a different case version of itself in a case-insensitive # search). continue_from_top = False if found: cursor = self._editor.textCursor() cursor.insertText(replacement) n += 1 grouping.endEditBlock() self._editor.setTextCursor(grouping) self._editor.verticalScrollBar().setValue(scrollpos) self.find_action.emit('Replaced ' + str(n) + ' occurrences.') def activate(self): """ Activates this dialog. """ # Check for selection cursor = self._editor.textCursor() if cursor.hasSelection(): self._search_field.setText(cursor.selectedText()) # Show dialog self.show() self.raise_() self.activateWindow() # Set focus self._search_field.selectAll() self._search_field.setFocus() def case_sensitive(self): """ Returns ``True`` if this dialog is set for case-sensitive searching. """ return self._case_check.isChecked() def keyPressEvent(self, event): """ Qt event: A key-press reaches the dialog. """ key = event.key() if key == Qt.Key_Enter or key == Qt.Key_Return: self.action_find() else: super(FindDialog, self).keyPressEvent(event) def set_case_sensitive(self, case_sensitive): """ Sets/unsets the case-sensitive option. """ return self._case_check.setChecked(bool(case_sensitive)) def set_whole_word(self, whole_word): """ Sets/unsets the whole-word option. """ return self._whole_check.setChecked(bool(whole_word)) def whole_word(self): """ Returns ``True`` if this dialog is set for whole-word searching. """ return self._whole_check.isChecked()
class VideoView(QtWidgets.QGraphicsView): """ Views a color data scene. """ # Signals # The view was resized resize_event = QtCore.Signal() def __init__(self, scene): super(VideoView, self).__init__(scene) # Disable scrollbars (they can cause a cyclical resizing event!) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # Always track mouse position self.setMouseTracking(True) # Set crosshair cursor self.setCursor(Qt.CrossCursor) # Set rendering hints self.setRenderHint(QtGui.QPainter.Antialiasing) self.setViewportUpdateMode( QtWidgets.QGraphicsView.BoundingRectViewportUpdate) # Fit scene rect in view self.fitInView(self.sceneRect(), keepAspect=True) self.setAlignment(Qt.AlignCenter) # Delayed resizing self._resize_timer = QtCore.QTimer() self._resize_timer.timeout.connect(self._resize_timeout) def fitInView(self, rect, keepAspect=False): """ For some reason, Qt has a stupid bug in it that gives the scene a (hardcoded) margin of 2px. To remove, this is a re-implementation of the fitInView method, loosely based on the original C. https://bugreports.qt-project.org/browse/QTBUG-11945 """ # Reset the view scale try: unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1)) except AttributeError: # PyQt4 unity = self.matrix().mapRect(QtCore.QRectF(0, 0, 1, 1)) w, h = max(1, unity.width()), max(1, unity.height()) self.scale(1. / w, 1. / h) # Find the ideal scaling ratio viewRect = self.viewport().rect() try: sceneRect = self.transform().mapRect(rect) except AttributeError: # PyQt4 sceneRect = self.matrix().mapRect(rect) xr = viewRect.width() / sceneRect.width() yr = viewRect.height() / sceneRect.height() if keepAspect: xr = xy = min(xr, yr) # Scale and center self.scale(xr, yr) self.centerOn(rect.center()) def resizeEvent(self, event=None): """ Called when the view is resized. """ # Tell others a resize is happening # (This is used to pause the video playback) self.resize_event.emit() # Schedule delayed (and grouped) resize action self._resize_timer.start(100) def _resize_timeout(self, event=None): """ Called a few ms after time out. """ #import time #print(str(time.time()) + 'Resize!') self._resize_timer.stop() self.fitInView(self.sceneRect(), keepAspect=True)
class VideoScene(QtWidgets.QGraphicsScene): """ Color data display scene. """ # Signals # Somebody moved the mouse # Attributes: cell x, cell y mouse_moved = QtCore.Signal(int, int) # Single click # Attributes: cell x, cell y single_click = QtCore.Signal(float, float) # Double click! # Attributes: cell x, cell y double_click = QtCore.Signal(float, float) def __init__(self, *args): super(VideoScene, self).__init__(*args) self.setBackgroundBrush(QtGui.QColor(192, 192, 192)) self._w = None self._h = None self._p = None self.resize(1, 1, 0) def mousePressEvent(self, event): """ Single-click """ if event.button() == QtCore.Qt.LeftButton: if event.modifiers() == Qt.NoModifier: p = event.scenePos() x, y = int(p.x()), int(p.y()) if x >= 0 and x < self._w and y >= 0 and y < self._h: self.single_click.emit(x, y) return def mouseDoubleClickEvent(self, event): """ Double-click """ if event.button() == QtCore.Qt.LeftButton: if event.modifiers() == Qt.NoModifier: p = event.scenePos() x, y = int(p.x()), int(p.y()) if x >= 0 and x < self._w and y >= 0 and y < self._h: self.double_click.emit(x, y) return def mouseMoveEvent(self, event): """ Show mouse position in status bar """ p = event.scenePos() x, y = int(p.x()), int(p.y()) self.mouse_moved.emit(x, y) def resize(self, w, h, p): """ Resizes the scene to match the given dimensions. """ self._w = float(w) self._h = float(h) self._p = float(p) # Add room for colormap self.setSceneRect(0, 0, self._w + self._p, self._h)
class GraphArea(QtWidgets.QWidget): """ Area that can draw several graphs. """ # Signals # Somebody moved the mouse # Attributes: cell x, cell y mouse_moved = QtCore.Signal(float, float) def __init__(self): super(GraphArea, self).__init__() self._data = None self._time = None self._temp_index = None self._temp_path = None self._frozen = collections.OrderedDict() self._scaling = {} self._position = 0 self._color_temp = Qt.black self._color_cycle = [ Qt.red, #Qt.green, Qt.blue, #Qt.cyan, Qt.magenta, #Qt.yellow, Qt.darkRed, Qt.darkGreen, Qt.darkBlue, Qt.darkCyan, Qt.darkMagenta, Qt.darkYellow, ] self._sw = 1.0 self._sh = 1.0 def clear(self): """ Removes all graphs from the widget. """ self._frozen = collections.OrderedDict() self._scaling = {} self._temp_path = self._temp_index = None self.update() def freeze(self): """ Adds the temporary graph to the set of frozen graphs. """ if self._temp_index and self._temp_path: self._frozen[self._temp_index] = self._temp_path self._temp_index = self._temp_path = None self.update() def graph(self, variable, x, y): """ Adds temporary graph to this widget. """ if self._data is None: return # Create index, check for duplicates variable = str(variable) x, y = int(x), int(y) index = (x, y, variable) if index == self._temp_index: return if index in self._frozen: self._temp_index = self._temp_path = None self.update() return # Get scaling info try: ymin, ymax = self._scaling[variable] except KeyError: data = self._data.get2d(variable) ymin = np.min(data) ymax = np.max(data) d = ymax - ymin ymin -= 0.05 * d ymax += 0.05 * d if ymin == ymax: ymin -= 1 ymax += 1 self._scaling[variable] = (ymin, ymax) # Create path, using real time and scaled y data xx = iter(self._tpad) yy = (self._data.trace(variable, x, y) - ymin) / (ymax - ymin) yy = iter(1 - yy) path = QtGui.QPainterPath() x, y = xx.next(), yy.next() path.moveTo(x, y) for i in xrange(1, len(self._time)): x, y = xx.next(), yy.next() path.lineTo(x, y) self._temp_index = index self._temp_path = path # Update! self.update() def log(self): """ Returns a myokit DataLog containing the data currently displayed in the graph area. """ d = myokit.DataLog() if self._data: d['engine.time'] = self._data.time() for index in self._frozen.iterkeys(): x, y, variable = index d[variable, x, y] = self._data.trace(variable, x, y) if self._temp_index: x, y, variable = self._temp_index d[variable, x, y] = self._data.trace(variable, x, y) return d def minimumSizeHint(self): """ Returns a minimum size. """ return QtCore.QSize(250, 5) def mouseMoveEvent(self, event): """ Show mouse position in status bar """ p = event.pos() x, y = float(p.x()), float(p.y()) self.mouse_moved.emit(x * self._sw, y * self._sh) def paintEvent(self, event): """ Draws all the graphs. """ if self._data is None: return # Create painter painter = QtGui.QPainter() painter.begin(self) # Fill background painter.fillRect(self.rect(), QtGui.QBrush(Qt.white)) # Create coordinate system for graphs painter.scale(self.width() / self._tmax, self.height()) painter.setRenderHint(QtGui.QPainter.Antialiasing) # Create pen pen = QtGui.QPen() pen.setWidth(0) # Draw frozen graphs colors = iter(self._color_cycle) for path in self._frozen.itervalues(): try: pen.setColor(colors.next()) except StopIteration: colors = iter(self._color_cycle) pen.setColor(colors.next()) painter.setPen(pen) painter.drawPath(path) # Draw temp graph if self._temp_path: pen.setColor(self._color_temp) painter.setPen(pen) painter.drawPath(self._temp_path) # Show time indicator pen.setColor(Qt.red) painter.setPen(pen) painter.drawLine(self._position, 0, self._position, 1) # Finish painter.end() def resizeEvent(self, e=None): """ Resized. """ s = self.size() w, h = s.width(), s.height() self._sw = 1.0 / w if w > 0 else 1 self._sh = 1.0 / h if h > 0 else 1 def set_data(self, data): """ Passes in the DataBlock2d this graph area extracts its data from. """ self.clear() self._data = data self._time = data.time() tmin, tmax = self._time[0], self._time[-1] tpad = 0.01 * (tmax - tmin) self._tpad = self._time + tpad self._tmax = self._time[-1] + 2 * tpad def set_position(self, pos): """ Sets the position of the time indicator. """ if self._data: self._position = self._tpad[int(pos)] self.update() def sizeHint(self): """ Returns a size suggestion. """ return QtCore.QSize(250, 250) def sizePolicy(self): """ Tells Qt that this widget shout expand. """ return QtCore.QSizePolicy.Expanding
class GraphArea(QtWidgets.QWidget): """ Area that can draw several graphs. """ # Signals # Somebody moved the mouse # Attributes: cell x, cell y mouse_moved = QtCore.Signal(float, float) def __init__(self): super(GraphArea, self).__init__() # DataBlock 2d, and its time vector self._data = None self._time = None # Index (x, y, variable) of temporary graph (if any) self._temp_index = None self._temp_path = None # Map from indices to paths for all frozen graphs self._frozen = collections.OrderedDict() # Last variable used in temp or frozen graph: used to scale mouse y # coordinate self._last_variable = None # Scaling per variable self._scaling = {} # Time scaled to fit self._time_scaled = None self._tmin = 0 self._tmax = 1 self._trange = self._tmax - self._tmin # Current position in time self._position = self._tmin # Colours for drawing self._color_temp = Qt.black self._color_cycle = [ Qt.red, #Qt.green, Qt.blue, #Qt.cyan, Qt.magenta, #Qt.yellow, Qt.darkRed, Qt.darkGreen, Qt.darkBlue, Qt.darkCyan, Qt.darkMagenta, Qt.darkYellow, ] # Scaling factors from pixels to normalised (0, 1) coordinates. Updated # after every resize. self._sw = 1.0 self._sh = 1.0 def clear(self): """ Removes all graphs from the widget. """ self._frozen = collections.OrderedDict() self._scaling = {} self._temp_path = self._temp_index = None self._last_variable = None self.update() def freeze(self): """ Adds the temporary graph to the set of frozen graphs. """ if self._temp_index and self._temp_path: self._frozen[self._temp_index] = self._temp_path self._temp_index = self._temp_path = None self.update() def graph(self, variable, x, y): """ Adds temporary graph to this widget. """ if self._data is None: return # Create index, check for duplicates variable = self._last_variable = str(variable) x, y = int(x), int(y) index = (x, y, variable) if index == self._temp_index: return if index in self._frozen: self._temp_index = self._temp_path = None self.update() return # Get scaling info try: ymin, ymax = self._scaling[variable] except KeyError: data = self._data.get2d(variable) ymin = np.min(data) ymax = np.max(data) d = ymax - ymin ymin -= 0.05 * d ymax += 0.05 * d if ymin == ymax: ymin -= 1 ymax += 1 self._scaling[variable] = (ymin, ymax) # Create path, using real time and scaled y data xx = iter(self._time_scaled) yy = (self._data.trace(variable, x, y) - ymin) / (ymax - ymin) yy = iter(1 - yy) path = QtGui.QPainterPath() x, y = next(xx), next(yy) path.moveTo(x, y) for i in range(1, len(self._time)): x, y = next(xx), next(yy) path.lineTo(x, y) self._temp_index = index self._temp_path = path # Update! self.update() def log(self): """ Returns a myokit DataLog containing the data currently displayed in the graph area. """ d = myokit.DataLog() if self._data is not None: d['engine.time'] = self._data.time() for index in self._frozen.keys(): x, y, variable = index d[variable, x, y] = self._data.trace(variable, x, y) if self._temp_index: x, y, variable = self._temp_index d[variable, x, y] = self._data.trace(variable, x, y) return d def minimumSizeHint(self): """ Returns a minimum size. """ return QtCore.QSize(250, 5) def mouseMoveEvent(self, event): """ Trigger mouse moved event with graph coordinates. """ if self._last_variable is None: return # Get normalised x, y coordinates ([0, 1]) p = event.pos() x = float(p.x()) * self._sw y = 1 - float(p.y()) * self._sh # Scale x-axis according to time x = self._tmin + x * self._trange # Scale y-axis according to last shown variable ymin, ymax = self._scaling[self._last_variable] y = ymin + y * (ymax - ymin) # Emit event self.mouse_moved.emit(x, y) def paintEvent(self, event): """ Draws all the graphs. """ if self._data is None: return # Create painter painter = QtGui.QPainter() painter.begin(self) # Fill background painter.fillRect(self.rect(), QtGui.QBrush(Qt.white)) # Create coordinate system for graphs painter.scale(self.width(), self.height()) painter.setRenderHint(QtGui.QPainter.Antialiasing) # Create pen pen = QtGui.QPen() pen.setWidth(0) # Draw frozen graphs colors = iter(self._color_cycle) for path in self._frozen.values(): try: pen.setColor(next(colors)) except StopIteration: colors = iter(self._color_cycle) pen.setColor(next(colors)) painter.setPen(pen) painter.drawPath(path) # Draw temp graph if self._temp_path: pen.setColor(self._color_temp) painter.setPen(pen) painter.drawPath(self._temp_path) # Show time indicator pen.setColor(Qt.red) painter.setPen(pen) t = (self._position - self._tmin) / self._trange painter.drawLine(QtCore.QLineF(t, 0, t, 1)) # Finish painter.end() def resizeEvent(self, e=None): """ Resized. """ s = self.size() w, h = s.width(), s.height() self._sw = 1.0 / w if w > 0 else 1 self._sh = 1.0 / h if h > 0 else 1 def set_data(self, data): """ Passes in the DataBlock2d this graph area extracts its data from. """ self.clear() self._data = data self._time = data.time() self._tmin = self._time[0] self._tmax = self._time[-1] self._trange = self._tmax - self._tmin tpad = 0.01 * self._trange self._tmin -= tpad self._tmax += tpad self._trange += 2 * tpad self._time_scaled = (self._time - self._tmin) / self._trange self._position = self._tmin def set_position(self, pos): """ Sets the position of the time indicator. """ if self._data is not None: self._position = self._time[int(pos)] self.update() def sizeHint(self): """ Returns a size suggestion. """ return QtCore.QSize(250, 250) def sizePolicy(self): """ Tells Qt that this widget shout expand. """ return QtCore.QSizePolicy.Expanding
class FindReplaceWidget(QtWidgets.QWidget): """ Find/replace widget for :class:`Editor`. """ # Signal: Find action happened, update with text # Attributes: (description) find_action = QtCore.Signal(str) def __init__(self, parent, editor): super(FindReplaceWidget, self).__init__(parent) self._editor = editor # Create widgets self._replace_all_button = QtWidgets.QPushButton('Replace all') self._replace_all_button.clicked.connect(self.action_replace_all) self._replace_button = QtWidgets.QPushButton('Replace') self._replace_button.clicked.connect(self.action_replace) self._find_button = QtWidgets.QPushButton('Find') self._find_button.clicked.connect(self.action_find) self._search_label = QtWidgets.QLabel('Search for') self._search_field = QtWidgets.QLineEdit() self._replace_label = QtWidgets.QLabel('Replace with') self._replace_field = QtWidgets.QLineEdit() self._case_check = QtWidgets.QCheckBox('Case sensitive') self._whole_check = QtWidgets.QCheckBox('Match whole word only') # Create layout text_layout = QtWidgets.QGridLayout() text_layout.addWidget(self._search_label, 0, 0) text_layout.addWidget(self._search_field, 0, 1) text_layout.addWidget(self._replace_label, 1, 0) text_layout.addWidget(self._replace_field, 1, 1) check_layout = QtWidgets.QBoxLayout(QtWidgets.QBoxLayout.TopToBottom) check_layout.addWidget(self._case_check) check_layout.addWidget(self._whole_check) button_layout = QtWidgets.QGridLayout() button_layout.addWidget(self._replace_all_button, 0, 1) button_layout.addWidget(self._replace_button, 0, 2) button_layout.addWidget(self._find_button, 0, 3) layout = QtWidgets.QBoxLayout(QtWidgets.QBoxLayout.TopToBottom) layout.addLayout(text_layout) layout.addLayout(check_layout) layout.addLayout(button_layout) layout.addStretch(1) self.setLayout(layout) # Accept keyboard focus on search and replace fields self._search_field.setEnabled(True) self._replace_field.setEnabled(True) def action_find(self): """ Qt slot: Find (next) item. """ query = self._search_field.text() if query == '': self.find_action.emit('No query set') return flags = 0x0 if self._case_check.isChecked(): flags |= QtGui.QTextDocument.FindCaseSensitively if self._whole_check.isChecked(): flags |= QtGui.QTextDocument.FindWholeWords if flags: found = self._editor.find(query, flags) else: found = self._editor.find(query) if found is False: # Not found? Try from top of document previous_cursor = self._editor.textCursor() previous_scroll = self._editor.verticalScrollBar().value() cursor = self._editor.textCursor() cursor.setPosition(0) self._editor.setTextCursor(cursor) if flags: found = self._editor.find(query, flags) else: found = self._editor.find(query) if found is False: self._editor.setTextCursor(previous_cursor) self._editor.verticalScrollBar().setValue(previous_scroll) self.find_action.emit('Query not found.') return cursor = self._editor.textCursor() line = 1 + cursor.blockNumber() char = cursor.selectionStart() - cursor.block().position() self.find_action.emit( 'Match found on line ' + str(line) + ' char ' + str(char) + '.') def action_replace(self): """ Qt slot: Replace found item with replacement. """ query = self._search_field.text() replacement = self._replace_field.text() if query == '': self.find_action.emit('No query set') return cursor = self._editor.textCursor() a, b = cursor.selectedText(), query if not self._case_check.isChecked(): a, b = a.lower(), b.lower() if a == b: cursor.insertText(replacement) self.action_find() def action_replace_all(self): """ Qt slot: Replace all found items with replacement """ query = self._search_field.text() replacement = self._replace_field.text() if query == '': self.find_action.emit('No query set') return flags = 0x0 if self._case_check.isChecked(): flags |= QtGui.QTextDocument.FindCaseSensitively if self._whole_check.isChecked(): flags |= QtGui.QTextDocument.FindWholeWords n = 0 found = True scrollpos = self._editor.verticalScrollBar().value() grouping = self._editor.textCursor() grouping.beginEditBlock() continue_from_top = True while found: if flags: found = self._editor.find(query, flags) else: found = self._editor.find(query) if not found and continue_from_top: # Not found? Try from top of document cursor = self._editor.textCursor() cursor.setPosition(0) self._editor.setTextCursor(cursor) if flags: found = self._editor.find(query, flags) else: found = self._editor.find(query) # Don't keep going round and round # (This can happen if you replace something with itself, or # with a different case version of itself in a case-insensitive # search). continue_from_top = False if found: cursor = self._editor.textCursor() cursor.insertText(replacement) n += 1 grouping.endEditBlock() self._editor.setTextCursor(grouping) self._editor.verticalScrollBar().setValue(scrollpos) self.find_action.emit('Replaced ' + str(n) + ' occurrences.') def activate(self): """ Updates the contents of the search field and gives it focus. """ cursor = self._editor.textCursor() if cursor.hasSelection(): self._search_field.setText(cursor.selectedText()) self._search_field.selectAll() self._search_field.setFocus() def keyPressEvent(self, event): """ Qt event: A key-press reaches the widget. """ key = event.key() if key == Qt.Key_Enter or key == Qt.Key_Return: self.action_find() else: super(FindReplaceWidget, self).keyPressEvent(event) def load_config(self, config, section): """ Loads this search's configuration using the given :class:`ConfigParser` ``config``. Loads all settings from the section ``section``. """ if config.has_section(section): # Find options: case sensitive / whole word if config.has_option(section, 'case_sensitive'): self._case_check.setChecked( config.getboolean(section, 'case_sensitive')) if config.has_option(section, 'whole_word'): self._whole_check.setChecked( config.getboolean(section, 'whole_word')) def save_config(self, config, section): """ Saves this search's configuration using the given :class:`ConfigParser` ``config``. Stores all settings in the section ``section``. """ config.add_section(section) # Find options: case sensitive / whole word config.set(section, 'case_sensitive', self._case_check.isChecked()) config.set(section, 'whole_word', self._whole_check.isChecked())