diff --git a/.coveragerc b/.coveragerc index 9aa183ac..11476124 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,6 +2,9 @@ timid = True branch = True source = src +omit = + cq_editor/__main__.py + cq_editor/widgets/pyhighlight.py [report] exclude_lines = diff --git a/appveyor.yml b/appveyor.yml index 9dbb6044..4797723a 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -13,7 +13,7 @@ environment: install: - sh: if [[ $APPVEYOR_BUILD_WORKER_IMAGE != "macOS"* ]]; then sudo apt update; sudo apt -y --force-yes install libglu1-mesa xvfb libgl1-mesa-dri mesa-common-dev libglu1-mesa-dev; fi - - sh: if [[ $APPVEYOR_BUILD_WORKER_IMAGE != "macOS"* ]]; then curl -fsSL -o miniconda.sh https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-x86_64.sh; fi + - sh: if [[ $APPVEYOR_BUILD_WORKER_IMAGE != "macOS"* ]]; then curl -fsSL -o miniconda.sh https://github.com/conda-forge/miniforge/releases/download/25.3.1-0/Miniforge3-Linux-x86_64.sh; fi - sh: if [[ $APPVEYOR_BUILD_WORKER_IMAGE == "macOS"* ]]; then curl -fsSL -o miniconda.sh https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Darwin-x86_64.sh; fi - sh: bash miniconda.sh -b -p $HOME/miniconda - sh: source $HOME/miniconda/bin/activate diff --git a/conda/meta.yaml b/conda/meta.yaml index 20856f72..423d2076 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -14,21 +14,21 @@ build: - CQ-editor = cq_editor.__main__:main requirements: build: - - python >=3.8 + - python >=3.10 - setuptools run: - - python >=3.9 + - python >=3.10 - cadquery=master - ocp - logbook - pyqt=5.* - pyqtgraph - - spyder >=5.5.6,<6 + - qtawesome=1.4.0 - path - logbook - requests - - qtconsole >=5.5.1,<5.6.0 + - qtconsole >=5.5.1,<5.7.0 test: imports: - cq_editor diff --git a/cq_editor/__main__.py b/cq_editor/__main__.py index 2298ea56..c56f87c8 100644 --- a/cq_editor/__main__.py +++ b/cq_editor/__main__.py @@ -18,9 +18,16 @@ def main(): args = parser.parse_args(app.arguments()[1:]) - win = MainWindow(filename=args.filename if args.filename else None) - win.show() - sys.exit(app.exec_()) + # sys.exit(app.exec_()) + + try: + win = MainWindow(filename=args.filename if args.filename else None) + win.show() + app.exec_() + except Exception as e: + import traceback + + traceback.print_exc() if __name__ == "__main__": diff --git a/cq_editor/icons.py b/cq_editor/icons.py index c383d845..6b6440b1 100644 --- a/cq_editor/icons.py +++ b/cq_editor/icons.py @@ -15,11 +15,11 @@ import qtawesome as qta _icons_specs = { - "new": (("fa.file-o",), {}), - "open": (("fa.folder-open-o",), {}), + "new": (("fa5.file",), {}), + "open": (("fa5.folder-open",), {}), # borrowed from spider-ide "autoreload": [ - ("fa.repeat", "fa.clock-o"), + ("fa5s.redo-alt", "fa5.clock"), { "options": [ {"scale_factor": 0.75, "offset": (-0.1, -0.1)}, @@ -27,9 +27,9 @@ ] }, ], - "save": (("fa.save",), {}), + "save": (("fa5.save",), {}), "save_as": ( - ("fa.save", "fa.pencil"), + ("fa5.save", "fa5s.pencil-alt"), { "options": [ { @@ -39,12 +39,13 @@ ] }, ), - "run": (("fa.play",), {}), - "delete": (("fa.trash",), {}), + "run": (("fa5s.play",), {}), + "debug": (("fa5s.bug",), {}), + "delete": (("fa5s.trash",), {}), "delete-many": ( ( - "fa.trash", - "fa.trash", + "fa5s.trash", + "fa5s.trash", ), { "options": [ @@ -53,16 +54,16 @@ ] }, ), - "help": (("fa.life-ring",), {}), - "about": (("fa.info",), {}), - "preferences": (("fa.cogs",), {}), + "help": (("fa5s.life-ring",), {}), + "about": (("fa5s.info",), {}), + "preferences": (("fa5s.cogs",), {}), "inspect": ( - ("fa.cubes", "fa.search"), + ("fa5s.cubes", "fa5s.search"), {"options": [{"scale_factor": 0.8, "offset": (0, 0), "color": "gray"}, {}]}, ), - "screenshot": (("fa.camera",), {}), + "screenshot": (("fa5s.camera",), {}), "screenshot-save": ( - ("fa.save", "fa.camera"), + ("fa5.save", "fa5s.camera"), { "options": [ {"scale_factor": 0.8}, @@ -70,8 +71,13 @@ ] }, ), - "toggle-comment": (("fa.hashtag",), {}), - "search": (("fa.search",), {}), + "toggle-comment": (("fa5s.hashtag",), {}), + "search": (("fa5s.search",), {}), + "arrow-step-over": (("fa5s.step-forward",), {}), + "arrow-step-in": (("fa5s.angle-down",), {}), + "arrow-continue": (("fa5s.arrow-right",), {}), + "clear": (("fa5s.eraser",), {}), + "clear-2": (("fa5s.broom",), {}), } diff --git a/cq_editor/main_window.py b/cq_editor/main_window.py index 9807d60e..8ef3493a 100644 --- a/cq_editor/main_window.py +++ b/cq_editor/main_window.py @@ -323,6 +323,16 @@ def prepare_menubar(self): ) menu_edit.addAction(self.autocomplete_action) + # Add the menu action to open the code search controls + self.search_action = QAction( + icon("search"), + "Search", + self, + shortcut="ctrl+F", + triggered=self.components["editor"].search_widget.show_search, + ) + menu_edit.addAction(self.search_action) + menu_edit.addAction( QAction( icon("preferences"), diff --git a/cq_editor/preferences.py b/cq_editor/preferences.py index 0f290ca7..2badd740 100644 --- a/cq_editor/preferences.py +++ b/cq_editor/preferences.py @@ -63,7 +63,7 @@ def add(self, name, component): for child in component.preferences.children(): # Fill the editor color scheme drop down list if child.name() == "Color scheme": - child.setLimits(["Spyder", "Monokai", "Zenburn"]) + child.setLimits(["Light", "Dark"]) # Fill the camera projection type elif child.name() == "Projection Type": child.setLimits( diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py new file mode 100644 index 00000000..98af9cb9 --- /dev/null +++ b/cq_editor/widgets/code_editor.py @@ -0,0 +1,868 @@ +# Much of this code was adapted from https://github.com/leixingyu/codeEditor which is under +# an MIT license +import os +from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt5.QtGui import QPalette, QColor + + +DARK_BLUE = QtGui.QColor(118, 150, 185) + + +class SearchWidget(QtWidgets.QWidget): + def __init__(self, editor): + super(SearchWidget, self).__init__(editor) + self.editor = editor + self.current_match = 0 + self.total_matches = 0 + + self.setup_ui() + + # This widget should initially be hidden + self.hide() + + def setup_ui(self): + # Horizontal layout + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(5, 5, 5, 5) + + # Search input box + self.search_input = QtWidgets.QLineEdit() + self.search_input.setPlaceholderText("Search...") + self.search_input.setMinimumWidth(100) + self.search_input.textChanged.connect(self.on_search_text_changed) + self.search_input.returnPressed.connect(self.find_next) + + # Previous button + self.prev_button = QtWidgets.QPushButton("Prev") + self.prev_button.clicked.connect(self.find_previous) + self.prev_button.setEnabled(False) + + # Next button + self.next_button = QtWidgets.QPushButton("Next") + self.next_button.clicked.connect(self.find_next) + self.next_button.setEnabled(False) + + # Match count label + self.match_label = QtWidgets.QLabel("0 matches") + + # Close button + self.close_button = QtWidgets.QPushButton("×") + self.close_button.setMaximumSize(20, 20) + self.close_button.clicked.connect(self.hide_search) + + # Add widgets to layout + layout.addWidget(self.search_input) + layout.addWidget(self.prev_button) + layout.addWidget(self.next_button) + layout.addWidget(self.match_label) + layout.addWidget(self.close_button) + self.setLayout(layout) + + def on_search_text_changed(self, text): + """ + Called as the user types text into the search field. + """ + if not text: + self.clear_highlights() + self.update_match_count(0, 0) + self.prev_button.setEnabled(False) + self.next_button.setEnabled(False) + return + + self.find_all_matches(text) + + def find_all_matches(self, search_text): + """ + Finds all the matches within the search text. + """ + if not search_text: + return + + # Clear any previous highlights + self.clear_highlights() + + # Find all matches + document = self.editor.document() + cursor = QtGui.QTextCursor(document) + self.matches = [] + + # Find all occurrences + while True: + # Look for a match + cursor = document.find(search_text, cursor) + if cursor.isNull(): + break + self.matches.append(cursor) + + self.total_matches = len(self.matches) + + # If there are matches make them visible to the user + if self.total_matches > 0: + self.current_match = 0 + self.highlight_matches() + self.highlight_current_match() + self.prev_button.setEnabled(True) + self.next_button.setEnabled(True) + else: + self.prev_button.setEnabled(False) + self.next_button.setEnabled(False) + + self.update_match_count( + self.current_match + 1 if self.total_matches > 0 else 0, self.total_matches + ) + + def highlight_matches(self): + """ + Highlights all matches to make them visible. + """ + extra_selections = [] + + for cursor in self.matches: + selection = QtWidgets.QTextEdit.ExtraSelection() + selection.format.setBackground(QtGui.QColor(255, 255, 0, 100)) + selection.cursor = cursor + extra_selections.append(selection) + + self.editor.setExtraSelections(extra_selections) + + def highlight_current_match(self): + """ + Makes the current match stand out from the others. + """ + + # If there are no matches to highlight, then skip this step + if not self.matches or self.current_match >= len(self.matches): + return + + # Highlight current match more than others and scroll to it + extra_selections = [] + + for i, cursor in enumerate(self.matches): + selection = QtWidgets.QTextEdit.ExtraSelection() + # The current match should stand out + if i == self.current_match: + selection.format.setBackground(QtGui.QColor(255, 165, 0)) + else: + selection.format.setBackground(QtGui.QColor(255, 255, 0, 100)) + selection.cursor = cursor + extra_selections.append(selection) + + self.editor.setExtraSelections(extra_selections) + + # Scroll to the current match + self.editor.setTextCursor(self.matches[self.current_match]) + self.editor.ensureCursorVisible() + + def find_next(self): + """ + Finds the next match. + """ + + # If there are no matches, skip this step + if not self.matches: + return + + self.current_match = (self.current_match + 1) % len(self.matches) + self.highlight_current_match() + self.update_match_count(self.current_match + 1, self.total_matches) + + def find_previous(self): + """ + Finds the previous match. + """ + + # If there are no matches, skip this step + if not self.matches: + return + + self.current_match = (self.current_match - 1) % len(self.matches) + self.highlight_current_match() + self.update_match_count(self.current_match + 1, self.total_matches) + + def update_match_count(self, current, total): + """ + Updates the match count for the user. + """ + if total == 0: + self.match_label.setText("0 matches") + else: + self.match_label.setText(f"{current} of {total}") + + def clear_highlights(self): + """ + Clears all of the find highlights. + """ + self.editor.setExtraSelections([]) + self.matches = [] + + def show_search(self): + """ + Makes the search dialog visible. + """ + self.show() + + # Make sure the user can start typing search text right away + self.search_input.setFocus() + self.search_input.selectAll() + + self.position_widget() + + # If there is already text in the search box, trigger the search + if self.search_input.text(): + self.find_all_matches(self.search_input.text()) + + def hide_search(self): + """ + Hides the search dialog again. + """ + self.hide() + self.clear_highlights() + self.editor.setFocus() + + def position_widget(self): + """ + Makes sure that the search widget gets placed in the right location + in the window. + """ + + # Top-right corner of the editor + editor_rect = self.editor.geometry() + widget_width = 400 + widget_height = 40 + x = editor_rect.width() - widget_width - 20 + y = 10 + + # Set the size of the widget and bring it to the front + self.setGeometry(x, y, widget_width, widget_height) + self.raise_() + + +class LineNumberArea(QtWidgets.QWidget): + def __init__(self, editor): + super(LineNumberArea, self).__init__(editor) + self._code_editor = editor + + def sizeHint(self): + return QtCore.QSize(self._code_editor.line_number_area_width(), 0) + + def mousePressEvent(self, event): + """ + Handles mouse clicks to add/remove breakpoints. + """ + if event.button() == QtCore.Qt.LeftButton: + # Calculate which line was clicked + line_number = self.get_line_number_from_position(event.pos()) + if line_number is not None: + self._code_editor.toggle_breakpoint(line_number) + + def get_line_number_from_position(self, pos): + """ + Convert mouse position to line number. + """ + + # Get the first visible block + block = self._code_editor.firstVisibleBlock() + block_number = block.blockNumber() + offset = self._code_editor.contentOffset() + top = self._code_editor.blockBoundingGeometry(block).translated(offset).top() + + # Find which block the click position corresponds to + while block.isValid(): + bottom = top + self._code_editor.blockBoundingRect(block).height() + + if top <= pos.y() <= bottom: + return ( + block_number + 1 + ) # Line numbers start at 0 internally and 1 for the user + + block = block.next() + top = bottom + block_number += 1 + + return None + + def paintEvent(self, event): + self._code_editor.lineNumberAreaPaintEvent(event) + + +class EdgeLine(QtWidgets.QWidget): + edge_line = None + columns = 80 + + def __init__(self): + super(QtWidgets.QWidget, self).__init__() + + def set_enabled(self, enabled_state): + self.setEnabled = enabled_state + + def set_columns(self, number_of_columns): + self.columns = number_of_columns + + +class CodeTextEdit(QtWidgets.QPlainTextEdit): + is_first = False + pressed_keys = list() + + indented = QtCore.pyqtSignal(object) + unindented = QtCore.pyqtSignal(object) + + def __init__(self): + super(CodeTextEdit, self).__init__() + + self.indented.connect(self.do_indent) + self.unindented.connect(self.undo_indent) + + def clear_selection(self): + """ + Clear text selection on cursor + """ + cursor = self.textCursor() + pos = cursor.selectionEnd() + cursor.movePosition(pos) + self.setTextCursor(cursor) + + def get_selection_range(self): + """ + Get text selection line range from cursor + Note: currently only support continuous selection + + :return: (int, int). start line number and end line number + """ + cursor = self.textCursor() + if not cursor.hasSelection(): + return 0, 0 + + start_pos = cursor.selectionStart() + end_pos = cursor.selectionEnd() + + cursor.setPosition(start_pos) + start_line = cursor.blockNumber() + cursor.setPosition(end_pos) + end_line = cursor.blockNumber() + + return start_line, end_line + + def remove_line_start(self, string, line_number): + """ + Remove certain string occurrence on line start + + :param string: str. string pattern to remove + :param line_number: int. line number + """ + cursor = QtGui.QTextCursor(self.document().findBlockByLineNumber(line_number)) + cursor.select(QtGui.QTextCursor.LineUnderCursor) + text = cursor.selectedText() + if text.startswith(string): + cursor.removeSelectedText() + cursor.insertText(text.split(string, 1)[-1]) + + def insert_line_start(self, string, line_number): + """ + Insert certain string pattern on line start + + :param string: str. string pattern to insert + :param line_number: int. line number + """ + cursor = QtGui.QTextCursor(self.document().findBlockByLineNumber(line_number)) + self.setTextCursor(cursor) + self.textCursor().insertText(string) + + def keyPressEvent(self, event): + """ + Extend the key press event to create key shortcuts + """ + self.is_first = True + self.pressed_keys.append(event.key()) + start_line, end_line = self.get_selection_range() + + # indent event + if event.key() == QtCore.Qt.Key_Tab: + lines = range(start_line, end_line + 1) + self.indented.emit(lines) + return + elif event.key() == QtCore.Qt.Key_Tab and (end_line - start_line): + lines = range(start_line, end_line + 1) + self.indented.emit(lines) + return + # un-indent event + elif event.key() == QtCore.Qt.Key_Backtab: + lines = range(start_line, end_line + 1) + self.unindented.emit(lines) + return + + super(CodeTextEdit, self).keyPressEvent(event) + + def do_indent(self, lines): + """ + Indent lines + + :param lines: [int]. line numbers + """ + + # Get the selection range of lines + start_line, end_line = self.get_selection_range() + + # If a single line is selected, make sure it is the only line in the range + if start_line == 0 and end_line == 0: + # Insert a tab at the current cursor location + cursor = self.textCursor() + cursor.insertText(" ") + + # Make sure that no lines are changed + lines = [] + # Multiple lines have been selected + else: + lines = range(start_line, end_line + 1) + + # Walk through the selected lines and tab them (with 4 spaces) + for line in lines: + self.insert_line_start(" ", line) + + def undo_indent(self, lines): + """ + Un-indent lines + + :param lines: [int]. line numbers + """ + for line in lines: + self.remove_line_start(" ", line) + + # Set the cursor to the beginning of the last line + cursor = self.textCursor() + cursor.setPosition(cursor.selectionEnd()) # Move to end of selection + cursor.movePosition(QtGui.QTextCursor.StartOfLine) # Jump to start of line + self.setTextCursor(cursor) + + +class CodeEditor(CodeTextEdit): + def __init__(self, parent=None): + super(CodeEditor, self).__init__() + self.line_number_area = LineNumberArea(self) + + self.font = QtGui.QFont() + self.font.setFamily("Courier New") + self.font.setStyleHint(QtGui.QFont.Monospace) + self.font.setPointSize(10) + self.setFont(self.font) + + self.tab_size = 4 + self.setTabStopWidth(self.tab_size * self.fontMetrics().width(" ")) + + self.blockCountChanged.connect(self.update_line_number_area_width) + self.updateRequest.connect(self.update_line_number_area) + # self.cursorPositionChanged.connect(self.highlight_current_line) + + self.update_line_number_area_width(0) + # self.highlight_current_line() + + self.menu = QtWidgets.QMenu() + + self.edge_line = EdgeLine() + + self.search_widget = SearchWidget(self) + + self._filename = "" + + def keyPressEvent(self, event): + # Handle Ctrl+F for search + if ( + event.modifiers() == QtCore.Qt.ControlModifier + and event.key() == QtCore.Qt.Key_F + ): + self.search_widget.show_search() + return + + # Handle F3 for find next (when search widget is visible) + if event.key() == QtCore.Qt.Key_F3 and self.search_widget.isVisible(): + if event.modifiers() == QtCore.Qt.AltModifier: + self.search_widget.find_previous() # Alt+F3 for previous + else: + self.search_widget.find_next() # F3 for next + return + + # Handle Escape to close search + if event.key() == QtCore.Qt.Key_Escape and self.search_widget.isVisible(): + self.search_widget.hide_search() + return + + # Call parent for other keys + super(CodeEditor, self).keyPressEvent(event) + + def setup_editor( + self, + line_numbers=True, + markers=True, + edge_line=100, + tab_mode=False, + show_blanks=True, + font=QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont), + language="Python", + filename="", + ): + print("setup_editor called") + + def set_color_scheme(self, color_scheme): + """ + Sets the color theme of the editor widget. + :param str color_scheme: Name of the color theme to be set + """ + + if color_scheme == "Light": + self.setStyleSheet("") + self.setPalette(QtWidgets.QApplication.style().standardPalette()) + else: + # Now use a palette to switch to dark colors: + white_color = QColor(255, 255, 255) + black_color = QColor(0, 0, 0) + red_color = QColor(255, 0, 0) + palette = QPalette() + palette.setColor(QPalette.Window, QColor(53, 53, 53)) + palette.setColor(QPalette.WindowText, white_color) + palette.setColor(QPalette.Base, QColor(25, 25, 25)) + palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53)) + palette.setColor(QPalette.ToolTipBase, black_color) + palette.setColor(QPalette.ToolTipText, white_color) + palette.setColor(QPalette.Text, white_color) + palette.setColor(QPalette.Button, QColor(53, 53, 53)) + palette.setColor(QPalette.ButtonText, white_color) + palette.setColor(QPalette.BrightText, red_color) + palette.setColor(QPalette.Link, QColor(42, 130, 218)) + palette.setColor(QPalette.Highlight, QColor(42, 130, 218)) + palette.setColor(QPalette.HighlightedText, black_color) + self.setPalette(palette) + + def set_font(self, new_font): + self.font = new_font + self.setFont(new_font) + + def toggle_wrap_mode(self, wrap_mode): + self.setLineWrapMode(wrap_mode) + + def set_cursor_position(self, position): + """ + Allows the caller to set the position of the cursor within + the editor text. + """ + + cursor = self.textCursor() + cursor.setPosition(position) + self.setTextCursor(cursor) + + def go_to_line(self, line_number): + """ + Set the text cursor at a specific line number. + """ + + cursor = self.textCursor() + + # Line numbers start at 0 + block = self.document().findBlockByNumber(line_number - 1) + + cursor.setPosition(block.position()) + self.setTextCursor(cursor) + + def toggle_comment(self): + """ + High level method to comment or uncomment a single line, + or block of lines. + """ + + # See if there is a selection range + sel_range = self.get_selection_range() + if sel_range[0] == 0 and sel_range[1] == 0: + # Get the text of the line + cursor = self.textCursor() + cursor.movePosition(QtGui.QTextCursor.StartOfLine) + line_text = cursor.block().text() + + # Skip blank lines + if line_text == "": + return + + # Find the first non-whitespace character position + pos = 0 + while pos < len(line_text) and line_text[pos].isspace(): + pos += 1 + + # Move right by pos characters to the position before text starts + cursor.movePosition( + QtGui.QTextCursor.Right, QtGui.QTextCursor.MoveAnchor, pos + ) + cursor.movePosition( + QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, 1 + ) + + # If we have a single line comment, remove it + if cursor.selectedText() == "#": + cursor.removeSelectedText() + + # Remove any whitespace after the comment character + cursor.movePosition( + QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, 1 + ) + if cursor.selectedText() == " ": + cursor.removeSelectedText() + else: + # Insert the comment characters + cursor.movePosition( + QtGui.QTextCursor.Left, QtGui.QTextCursor.MoveAnchor, 1 + ) + cursor.insertText("# ") + else: + # Make the selected line numbers 1-based + sel_start = sel_range[0] + sel_end = sel_range[1] + cursor = self.textCursor() + + # Select the text block + block = self.document().findBlockByNumber(sel_start) + cursor.setPosition(block.position()) + last_block = self.document().findBlockByNumber(sel_end) + end_pos = last_block.position() + last_block.length() - 1 + cursor.setPosition(end_pos, QtGui.QTextCursor.KeepAnchor) + + # Find the left-most position to put the comment at + leftmost_pos = 99999 + comment_line_found = False + non_comment_line_found = False + blank_lines = [] + # Step through all of the selected lines and toggle their comments + for i in range(sel_start, sel_end + 1): + # Set the cursor to the current line number + block = self.document().findBlockByNumber(i) + cursor.setPosition(block.position()) + cursor.movePosition(cursor.EndOfBlock, cursor.KeepAnchor) + line_text = cursor.selectedText() + + # Make sure the line is not blank + if line_text == "": + blank_lines.append(i) + continue + + if line_text.strip()[0] == "#": + comment_line_found = True + else: + non_comment_line_found = True + + # Find the first non-whitespace character position + pos = 0 + while pos < len(line_text) and line_text[pos].isspace(): + pos += 1 + + # Save the left-most position + if pos < leftmost_pos: + leftmost_pos = pos + + # Step through all of the selected lines and toggle their comments + for i in range(sel_start, sel_end + 1): + # If this is a blank line, do not process it + if i in blank_lines: + continue + + # Set the cursor to the current line number + block = self.document().findBlockByNumber(i) + cursor.setPosition(block.position()) + + # See if we need to comment the whole block + if comment_line_found and non_comment_line_found: + # Insert the comment characters + cursor.insertText("# ") + else: + # Move right by pos characters to the position before text starts + cursor.movePosition( + QtGui.QTextCursor.Right, + QtGui.QTextCursor.MoveAnchor, + leftmost_pos, + ) + cursor.movePosition( + QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, 1 + ) + + # If the line starts with a hash, uncomment it. Otherwise comment it + if cursor.selectedText() == "#": + cursor.removeSelectedText() + + # Remove any whitespace after the comment character + cursor.movePosition( + QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, 1 + ) + if cursor.selectedText() == " ": + cursor.removeSelectedText() + else: + # Insert the comment characters + cursor.movePosition( + QtGui.QTextCursor.Left, QtGui.QTextCursor.MoveAnchor, 1 + ) + cursor.insertText("# ") + + def set_text(self, new_text): + """ + Sets the text content of the editor. + :param str new_text: Text to be set in the editor. + """ + # Set the text in the document + self.setPlainText(new_text) + + # Set the cursor at the end of the text + cursor = self.textCursor() + cursor.movePosition(cursor.End) + self.setTextCursor(cursor) + + # Set the document to be modified + self.document().setModified(True) + + def set_text_from_file(self, file_name): + """ + Allows the editor text to be set from a file. + :param str file_name: Full path of the file to be loaded into the editor. + """ + + self._filename = file_name + + # Load the text into the text field + with open(file_name, "r", encoding="utf-8") as file: + file_content = file.read() + + self.setPlainText(file_content) + + def get_text_with_eol(self): + """ + Returns a string representing the full text in the editor. + """ + return self.toPlainText() + + def line_number_area_width(self): + digits = 1 + max_num = max(1, self.blockCount()) + while max_num >= 10: + max_num *= 0.1 + digits += 1 + + space = 30 + self.fontMetrics().width("9") * digits + return space + + def resizeEvent(self, e): + super(CodeEditor, self).resizeEvent(e) + cr = self.contentsRect() + width = self.line_number_area_width() + rect = QtCore.QRect(cr.left(), cr.top(), width, cr.height()) + self.line_number_area.setGeometry(rect) + + def lineNumberAreaPaintEvent(self, event): + painter = QtGui.QPainter(self.line_number_area) + try: + # painter.fillRect(event.rect(), QtCore.Qt.lightGray) + block = self.firstVisibleBlock() + block_number = block.blockNumber() + offset = self.contentOffset() + top = self.blockBoundingGeometry(block).translated(offset).top() + bottom = top + self.blockBoundingRect(block).height() + + while block.isValid() and top <= event.rect().bottom(): + if block.isVisible() and bottom >= event.rect().top(): + line_number = block_number + 1 + + # Draw the breakpoint dot, if there is a breakpoint on this line + if self.line_has_breakpoint(line_number): + painter.setBrush( + QtGui.QBrush(QtGui.QColor(255, 0, 0)) + ) # Red circle + painter.setPen(QtGui.QPen(QtGui.QColor(150, 0, 0))) + circle_size = 10 + circle_x = 5 + circle_y = ( + int(top) + + (self.fontMetrics().height() - circle_size - 2) // 2 + ) + painter.drawEllipse( + circle_x, circle_y, circle_size, circle_size + ) + + # Draw the line number + number = str(line_number) + painter.setPen(DARK_BLUE) + width = self.line_number_area.width() - 10 + height = self.fontMetrics().height() + painter.drawText( + 0, int(top), width, height, QtCore.Qt.AlignRight, number + ) + + block = block.next() + top = bottom + bottom = top + self.blockBoundingRect(block).height() + block_number += 1 + finally: + painter.end() + + def update_line_number_area_width(self, newBlockCount): + self.setViewportMargins(self.line_number_area_width(), 0, 0, 0) + + def update_line_number_area(self, rect, dy): + if dy: + self.line_number_area.scroll(0, dy) + else: + width = self.line_number_area.width() + self.line_number_area.update(0, rect.y(), width, rect.height()) + + if rect.contains(self.viewport().rect()): + self.update_line_number_area_width(0) + + def highlight_current_line(self): + extra_selections = list() + if not self.isReadOnly(): + selection = QtWidgets.QTextEdit.ExtraSelection() + line_color = DARK_BLUE.lighter(160) + selection.format.setBackground(line_color) + selection.format.setProperty(QtGui.QTextFormat.FullWidthSelection, True) + + selection.cursor = self.textCursor() + selection.cursor.clearSelection() + extra_selections.append(selection) + self.setExtraSelections(extra_selections) + + def toggle_breakpoint(self, line_number): + """ + Toggle breakpoint on/off for a given line number. + """ + if line_number in self.debugger.breakpoints: + self.debugger.breakpoints.remove(line_number) + else: + self.debugger.breakpoints.append(line_number) + + # Repaint the line number area + self.line_number_area.update() + + def line_has_breakpoint(self, line_number): + """ + Checks if a line has a breakpoint. + """ + return line_number in self.debugger.breakpoints + + def paintEvent(self, event): + """ + Overrides the default paint event so that we can draw the line length indicator. + """ + + # Call the parent's paintEvent first to render the text + super(CodeEditor, self).paintEvent(event) + + painter = QtGui.QPainter(self.viewport()) + try: + # Calculate the x position for the line + font_metrics = self.fontMetrics() + char_width = font_metrics.width("M") # Use 'M' for average character width + x_position = self.edge_line.columns * char_width + self.contentOffset().x() + + # Only draw if the line is within the visible area + if 0 <= x_position <= self.viewport().width(): + # Set the pen color (light gray is common) + painter.setPen( + QtGui.QPen(QtGui.QColor(200, 200, 200), 1, QtCore.Qt.SolidLine) + ) + + # Draw the vertical line from top to bottom of the viewport + painter.drawLine( + int(x_position), 0, int(x_position), self.viewport().height() + ) + finally: + painter.end() diff --git a/cq_editor/widgets/console.py b/cq_editor/widgets/console.py index fea588e5..f28cfbfe 100644 --- a/cq_editor/widgets/console.py +++ b/cq_editor/widgets/console.py @@ -22,7 +22,7 @@ def __init__(self, customBanner=None, namespace=dict(), *args, **kwargs): self._actions = { "Run": [ QAction( - icon("delete"), "Clear Console", self, triggered=self.reset_console + icon("clear-2"), "Clear Console", self, triggered=self.reset_console ), ] } diff --git a/cq_editor/widgets/debugger.py b/cq_editor/widgets/debugger.py index b911f55d..7c529c82 100644 --- a/cq_editor/widgets/debugger.py +++ b/cq_editor/widgets/debugger.py @@ -21,7 +21,7 @@ from logbook import info from path import Path from pyqtgraph.parametertree import Parameter -from spyder.utils.icon_manager import icon +from ..icons import icon from random import randrange as rrr, seed from ..cq_utils import find_cq_objects, reload_cq @@ -199,6 +199,9 @@ def get_breakpoints(self): return self.parent().components["editor"].debugger.get_breakpoints() + def set_breakpoints(self, breakpoints): + return self.parent().components["editor"].debugger.set_breakpoints(breakpoints) + def compile_code(self, cq_script, cq_script_path=None): try: @@ -402,9 +405,9 @@ def trace_local(self, frame, event, arg): if ( self.state in (DbgState.STEP, DbgState.STEP_IN) and frame is self._frames[-1] - ) or (lineno in self.breakpoints): + ) or (lineno in self.get_breakpoints()): - if lineno in self.breakpoints: + if lineno in self.get_breakpoints(): self._frames.append(frame) self.sigLineChanged.emit(lineno) diff --git a/cq_editor/widgets/editor.py b/cq_editor/widgets/editor.py index 8c3cd91e..eba4bce9 100644 --- a/cq_editor/widgets/editor.py +++ b/cq_editor/widgets/editor.py @@ -1,8 +1,10 @@ import os -import spyder.utils.encoding + +# import spyder.utils.encoding from modulefinder import ModuleFinder -from spyder.plugins.editor.widgets.codeeditor import CodeEditor +from .code_editor import CodeEditor + from PyQt5.QtCore import pyqtSignal, QFileSystemWatcher, QTimer, Qt, QEvent from PyQt5.QtWidgets import ( QAction, @@ -12,7 +14,11 @@ QListWidgetItem, QShortcut, ) -from PyQt5.QtGui import QFontDatabase, QTextCursor, QKeyEvent +from PyQt5.QtGui import ( + QFontDatabase, + QTextCursor, + QKeyEvent, +) from path import Path import sys @@ -23,10 +29,23 @@ from ..mixins import ComponentMixin from ..utils import get_save_filename, get_open_filename, confirm +from .pyhighlight import PythonHighlighter from ..icons import icon +class EditorDebugger: + def __init__(self): + self.breakpoints = [] + + def get_breakpoints(self): + return self.breakpoints + + def set_breakpoints(self, breakpoints): + self.breakpoints = breakpoints + return True + + class Editor(CodeEditor, ComponentMixin): name = "Code Editor" @@ -52,16 +71,16 @@ class Editor(CodeEditor, ComponentMixin): { "name": "Color scheme", "type": "list", - "values": ["Spyder", "Monokai", "Zenburn"], - "value": "Spyder", + "values": ["Light", "Dark"], + "value": "Light", }, - {"name": "Maximum line length", "type": "int", "value": 88}, + {"name": "Maximum line length", "type": "int", "value": 79}, ], ) EXTENSIONS = "py" - # Tracks whether or not the document was saved from the Spyder editor vs an external editor + # Tracks whether or not the document was saved from the internal editor vs an external editor was_modified_by_self = False # Helps display the completion list for the editor @@ -71,11 +90,13 @@ def __init__(self, parent=None): self._watched_file = None + self.debugger = EditorDebugger() + super(Editor, self).__init__(parent) ComponentMixin.__init__(self) self.setup_editor( - linenumbers=True, + line_numbers=True, markers=True, edge_line=self.preferences["Maximum line length"], tab_mode=False, @@ -145,6 +166,8 @@ def __init__(self, parent=None): # Ensure that when the escape key is pressed with the completion_list in focus, it will be hidden self.completion_list.installEventFilter(self) + self.highlighter = PythonHighlighter(self.document()) + def eventFilter(self, watched, event): """ Allows us to do things like escape and tab key press for the completion list. @@ -177,16 +200,16 @@ def _fixContextMenu(self): menu = self.menu - menu.removeAction(self.run_cell_action) - menu.removeAction(self.run_cell_and_advance_action) - menu.removeAction(self.run_selection_action) - menu.removeAction(self.re_run_last_cell_action) + # menu.removeAction(self.run_cell_action) + # menu.removeAction(self.run_cell_and_advance_action) + # menu.removeAction(self.run_selection_action) + # menu.removeAction(self.re_run_last_cell_action) def updatePreferences(self, *args): self.set_color_scheme(self.preferences["Color scheme"]) - font = self.font() + font = self.font font.setPointSize(self.preferences["Font size"]) self.set_font(font) diff --git a/cq_editor/widgets/log.py b/cq_editor/widgets/log.py index afa3dcb0..91855820 100644 --- a/cq_editor/widgets/log.py +++ b/cq_editor/widgets/log.py @@ -54,7 +54,7 @@ def __init__(self, *args, **kwargs): self._actions = { "Run": [ - QAction(icon("delete"), "Clear Log", self, triggered=self.clear), + QAction(icon("clear"), "Clear Log", self, triggered=self.clear), ] } diff --git a/cq_editor/widgets/pyhighlight.py b/cq_editor/widgets/pyhighlight.py new file mode 100644 index 00000000..2875f51c --- /dev/null +++ b/cq_editor/widgets/pyhighlight.py @@ -0,0 +1,247 @@ +from PyQt5.QtCore import QRegExp +from PyQt5.QtGui import QColor, QTextCharFormat, QFont, QSyntaxHighlighter + + +def format(color, style=""): + """ + Return a QTextCharFormat with the given attributes. + """ + _color = QColor() + _color.setNamedColor(color) + + _format = QTextCharFormat() + _format.setForeground(_color) + if "bold" in style: + _format.setFontWeight(QFont.Bold) + if "italic" in style: + _format.setFontItalic(True) + + return _format + + +# Syntax styles that can be shared by all languages +STYLES = { + "keyword": format("blue"), + "operator": format("gray"), + "brace": format("darkGray"), + "defclass": format("gray", "bold"), + "string": format("orange"), + "string2": format("darkMagenta"), + "comment": format("darkGreen", "italic"), + "self": format("black", "italic"), + "numbers": format("magenta"), +} + + +class PythonHighlighter(QSyntaxHighlighter): + """ + Syntax highlighter for the Python language. + """ + + # Python keywords + keywords = [ + "and", + "assert", + "break", + "class", + "continue", + "def", + "del", + "elif", + "else", + "except", + "exec", + "finally", + "for", + "from", + "global", + "if", + "import", + "in", + "is", + "lambda", + "not", + "or", + "pass", + "print", + "raise", + "return", + "try", + "while", + "yield", + "None", + "True", + "False", + ] + + # Python operators + operators = [ + "=", + # Comparison + "==", + "!=", + "<", + "<=", + ">", + ">=", + # Arithmetic + "\+", + "-", + "\*", + "/", + "//", + "\%", + "\*\*", + # In-place + "\+=", + "-=", + "\*=", + "/=", + "\%=", + # Bitwise + "\^", + "\|", + "\&", + "\~", + ">>", + "<<", + ] + + # Python braces + braces = [ + "\{", + "\}", + "\(", + "\)", + "\[", + "\]", + ] + + def __init__(self, parent=None): + super(PythonHighlighter, self).__init__(parent) + + # Multi-line strings (expression, flag, style) + self.tri_single = (QRegExp("'''"), 1, STYLES["string2"]) + self.tri_double = (QRegExp('"""'), 2, STYLES["string2"]) + + rules = [] + + # Keyword, operator, and brace rules + rules += [ + (r"\b%s\b" % w, 0, STYLES["keyword"]) for w in PythonHighlighter.keywords + ] + rules += [ + (r"%s" % o, 0, STYLES["operator"]) for o in PythonHighlighter.operators + ] + rules += [(r"%s" % b, 0, STYLES["brace"]) for b in PythonHighlighter.braces] + + # All other rules + rules += [ + # 'self' + (r"\bself\b", 0, STYLES["self"]), + # 'def' followed by an identifier + (r"\bdef\b\s*(\w+)", 1, STYLES["defclass"]), + # 'class' followed by an identifier + (r"\bclass\b\s*(\w+)", 1, STYLES["defclass"]), + # Numeric literals + (r"\b[+-]?[0-9]+[lL]?\b", 0, STYLES["numbers"]), + (r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", 0, STYLES["numbers"]), + (r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b", 0, STYLES["numbers"]), + # Double-quoted string, possibly containing escape sequences + (r'"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES["string"]), + # Single-quoted string, possibly containing escape sequences + (r"'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES["string"]), + # From '#' until a newline + (r"#[^\n]*", 0, STYLES["comment"]), + ] + + # Build a QRegExp for each pattern + self.rules = [(QRegExp(pat), index, fmt) for (pat, index, fmt) in rules] + + def highlightBlock(self, text): + """ + Apply syntax highlighting to the given block of text. + """ + self.tripleQuoutesWithinStrings = [] + # Do other syntax formatting + for expression, nth, format in self.rules: + index = expression.indexIn(text, 0) + if index >= 0: + # if there is a string we check + # if there are some triple quotes within the string + # they will be ignored if they are matched again + if expression.pattern() in [ + r'"[^"\\]*(\\.[^"\\]*)*"', + r"'[^'\\]*(\\.[^'\\]*)*'", + ]: + innerIndex = self.tri_single[0].indexIn(text, index + 1) + if innerIndex == -1: + innerIndex = self.tri_double[0].indexIn(text, index + 1) + + if innerIndex != -1: + tripleQuoteIndexes = range(innerIndex, innerIndex + 3) + self.tripleQuoutesWithinStrings.extend(tripleQuoteIndexes) + + while index >= 0: + # skipping triple quotes within strings + if index in self.tripleQuoutesWithinStrings: + index += 1 + expression.indexIn(text, index) + continue + + # We actually want the index of the nth match + index = expression.pos(nth) + length = len(expression.cap(nth)) + self.setFormat(index, length, format) + index = expression.indexIn(text, index + length) + + self.setCurrentBlockState(0) + + # Do multi-line strings + in_multiline = self.match_multiline(text, *self.tri_single) + if not in_multiline: + in_multiline = self.match_multiline(text, *self.tri_double) + + def match_multiline(self, text, delimiter, in_state, style): + """ + Do highlighting of multi-line strings. ``delimiter`` should be a + ``QRegExp`` for triple-single-quotes or triple-double-quotes, and + ``in_state`` should be a unique integer to represent the corresponding + state changes when inside those strings. Returns True if we're still + inside a multi-line string when this function is finished. + """ + # If inside triple-single quotes, start at 0 + if self.previousBlockState() == in_state: + start = 0 + add = 0 + # Otherwise, look for the delimiter on this line + else: + start = delimiter.indexIn(text) + # skipping triple quotes within strings + if start in self.tripleQuoutesWithinStrings: + return False + # Move past this match + add = delimiter.matchedLength() + + # As long as there's a delimiter match on this line... + while start >= 0: + # Look for the ending delimiter + end = delimiter.indexIn(text, start + add) + # Ending delimiter on this line? + if end >= add: + length = end - start + add + delimiter.matchedLength() + self.setCurrentBlockState(0) + # No; multi-line string + else: + self.setCurrentBlockState(in_state) + length = len(text) - start + add + # Apply formatting + self.setFormat(start, length, style) + # Look for the next match + start = delimiter.indexIn(text, start + length) + + # Return True if still inside a multi-line string, False otherwise + if self.currentBlockState() == in_state: + return True + else: + return False diff --git a/cq_editor/widgets/viewer.py b/cq_editor/widgets/viewer.py index c2ada383..ed42ae27 100644 --- a/cq_editor/widgets/viewer.py +++ b/cq_editor/widgets/viewer.py @@ -182,7 +182,7 @@ def create_actions(self, parent): self._actions = { "View": [ QAction( - qta.icon("fa.arrows-alt"), + qta.icon("fa6s.maximize"), "Fit (Shift+F1)", parent, shortcut="shift+F1", @@ -238,14 +238,14 @@ def create_actions(self, parent): triggered=self.right_view, ), QAction( - qta.icon("fa.square-o"), + qta.icon("fa5.stop-circle"), "Wireframe (Shift+F9)", parent, shortcut="shift+F9", triggered=self.wireframe_view, ), QAction( - qta.icon("fa.square"), + qta.icon("fa5.square"), "Shaded (Shift+F10)", parent, shortcut="shift+F10", @@ -254,7 +254,7 @@ def create_actions(self, parent): ], "Tools": [ QAction( - icon("screenshot"), + qta.icon("fa5s.camera"), "Screenshot", parent, triggered=self.save_screenshot, diff --git a/cqgui_env.yml b/cqgui_env.yml index 9121df2a..cb19f4db 100644 --- a/cqgui_env.yml +++ b/cqgui_env.yml @@ -6,9 +6,9 @@ dependencies: - pyqt=5 - pyqtgraph - python=3.10 - - spyder >=5.5.6,<6 + - qtawesome=1.4.0 - path - logbook - requests - cadquery - - qtconsole >=5.5.1,<5.6.0 + - qtconsole >=5.5.1,<5.7.0 diff --git a/pyproject.toml b/pyproject.toml index cc4ae395..fa2e8a35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,15 +6,15 @@ build-backend = "setuptools.build_meta" name = "CQ-editor" version = "0.6.dev0" dependencies = [ - "cadquery", + "cadquery==2.5.2", "pyqtgraph", - "spyder>=5.5.6,<6", + "qtawesome==1.4.0", "path", "logbook", "requests", - "qtconsole>=5.5.1,<5.6.0" + "qtconsole>=5.5.1,<5.7.0" ] -requires-python = ">=3.9,<3.13" +requires-python = ">=3.10,<=3.13" authors = [ { name="CadQuery Developers" } ] diff --git a/tests/test_app.py b/tests/test_app.py index c8d0a8c3..12894dc4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -10,7 +10,7 @@ import pytestqt import cadquery as cq -from PyQt5.QtCore import Qt, QSettings, QPoint, QEvent +from PyQt5.QtCore import Qt, QSettings, QPoint, QEvent, QSize from PyQt5.QtWidgets import QFileDialog, QMessageBox from PyQt5.QtGui import QMouseEvent @@ -150,10 +150,10 @@ def main_clean(qtbot, mocker): win.show() qtbot.addWidget(win) - qtbot.waitForWindowShown(win) - editor = win.components["editor"] - editor.set_text(code) + with qtbot.waitExposed(win): + editor = win.components["editor"] + editor.set_text(code) return qtbot, win @@ -167,10 +167,10 @@ def main_clean_do_not_close(qtbot, mocker): win.show() qtbot.addWidget(win) - qtbot.waitForWindowShown(win) - editor = win.components["editor"] - editor.set_text(code) + with qtbot.waitExposed(win): + editor = win.components["editor"] + editor.set_text(code) return qtbot, win @@ -185,13 +185,13 @@ def main_multi(qtbot, mocker): win.show() qtbot.addWidget(win) - qtbot.waitForWindowShown(win) - editor = win.components["editor"] - editor.set_text(code_multi) + with qtbot.waitExposed(win): + editor = win.components["editor"] + editor.set_text(code_multi) - debugger = win.components["debugger"] - debugger._actions["Run"][0].triggered.emit() + debugger = win.components["debugger"] + debugger._actions["Run"][0].triggered.emit() return qtbot, win @@ -402,7 +402,7 @@ def check_no_error_occured(): assert number_visible_items(viewer) == 3 # check breakpoints - assert debugger.breakpoints == [] + assert debugger.set_breakpoints([]) # check _frames assert debugger._frames == [] @@ -557,6 +557,10 @@ def check_no_error_occured(): result = cq.Workplane("XY" ).box(3, 3, 0) """ +base_editor_text = """import cadquery as cq +result = cq.Workplane().box(10, 10, 10) +""" + def test_traceback(main): @@ -700,86 +704,244 @@ def filename2(*args, **kwargs): editor.restoreComponentState(settings) -@pytest.mark.repeat(1) -def test_editor_autoreload(monkeypatch, editor): - +def test_size_hint(editor): + """ + Tests the ability to get the size hit from the code editor widget. + """ qtbot, editor = editor - TIMEOUT = 500 + size_hint = editor.sizeHint() - # start out with autoreload enabled - editor.autoreload(True) + assert size_hint == QSize(256, 192) - with open("test.py", "w") as f: - f.write(code) +def test_clear_selection(editor): + """ + Tests the ability to clear selected text. + """ + qtbot, editor = editor + + # Set a block of text and make sure it is visible + editor.set_text(base_editor_text) + editor.document().setModified(False) + assert editor.get_text_with_eol() == base_editor_text + + # Remove all the text and make sure it was removed + editor.selectAll() + cursor = editor.textCursor() + cursor.removeSelectedText() + editor.setTextCursor(cursor) + editor.document().setModified(False) assert editor.get_text_with_eol() == "" - editor.load_from_file("test.py") - assert len(editor.get_text_with_eol()) > 0 + # Test the ability to deselect a selected area + editor.set_text(base_editor_text) + editor.selectAll() + assert editor.get_selection_range() == (0, 2) + editor.clear_selection() + assert editor.get_selection_range() == (0, 0) - # wait for reload. - with qtbot.waitSignal(editor.triggerRerender, timeout=TIMEOUT): - # modify file - NB: separate process is needed to avoid Widows quirks - modify_file(code_bigger_object) - # check that editor has updated file contents - assert code_bigger_object.splitlines()[2] in editor.get_text_with_eol() +def test_insert_remove_line_start(editor): + """ + Tests the ability to remove and insert characters from/to the beginning of a line. + """ + qtbot, editor = editor - # disable autoreload - editor.autoreload(False) + # Set a block of text and make sure it is visible + editor.set_text(base_editor_text) + editor.insert_line_start("# ", 0) + editor.document().setModified(False) + assert editor.get_text_with_eol() == "# " + base_editor_text - # Wait for reload in case it incorrectly happens. A timeout should occur - # instead because a re-render should not be triggered with autoreload - # disabled. - with pytest.raises(pytestqt.exceptions.TimeoutError): - with qtbot.waitSignal(editor.triggerRerender, timeout=TIMEOUT): - # re-write original file contents - modify_file(code) + # Remove the comment character from the line + editor.remove_line_start("# ", 0) + editor.document().setModified(False) + assert editor.get_text_with_eol() == base_editor_text - # editor should continue showing old contents since autoreload is disabled. - assert code_bigger_object.splitlines()[2] in editor.get_text_with_eol() - # Saving a file with autoreload disabled should not trigger a rerender. - with pytest.raises(pytestqt.exceptions.TimeoutError): - with qtbot.waitSignal(editor.triggerRerender, timeout=TIMEOUT): - editor.save() +def test_indent_unindent(editor): + """ + Check to make sure that indent and un-indent work properly. + """ + qtbot, editor = editor - editor.autoreload(True) + # Set the base text + editor.set_text(base_editor_text) + + # Indent the text and check + editor.selectAll() + qtbot.keyClick(editor, Qt.Key_Tab) + editor.document().setModified(False) + assert editor.get_text_with_eol() != base_editor_text + + # Unindent the code with a direct method call and check + editor.selectAll() + start_line, end_line = editor.get_selection_range() + # +1 here to compesate for how black wants the multi-line string + editor.undo_indent(list(range(start_line, end_line + 1))) + editor.document().setModified(False) + assert editor.get_text_with_eol() == base_editor_text + + # Indent the code again with a direct method call and check + editor.selectAll() + start_line, end_line = editor.get_selection_range() + editor.do_indent(list(range(start_line, end_line))) + editor.document().setModified(False) + assert editor.get_text_with_eol() != base_editor_text + + # Unindent the code again with a keystroke + editor.selectAll() + qtbot.keyClick(editor, Qt.Key_Backtab) + editor.document().setModified(False) + assert editor.get_text_with_eol() == base_editor_text + + # Indent just the second line + editor.clear_selection() + editor.do_indent([1]) + assert editor.get_text_with_eol() != base_editor_text + + +def test_set_color_scheme(editor): + """ + Make sure that the color theme can be switched without error. + """ + qtbot, editor = editor - # Saving a file with autoreload enabled should trigger a rerender. - with qtbot.waitSignal(editor.triggerRerender, timeout=TIMEOUT): - editor.save() + editor.set_color_scheme("Light") + editor.set_color_scheme("Dark") + + +def test_go_to_line(editor): + """ + Tests to make sure the caller can set the current line of the code. + """ + qtbot, editor = editor + + # Set the base text + editor.set_text(base_editor_text) + # Make sure the line changes + editor.go_to_line(1) + cursor = editor.textCursor() + block = cursor.block() + assert (block.blockNumber() + 1) == 1 + editor.go_to_line(2) + cursor = editor.textCursor() + block = cursor.block() + assert (block.blockNumber() + 1) == 2 -def test_autoreload_nested(editor): +def test_toggle_comment(editor): + """ + Tests to make sure that lines can be commented/uncommented. + """ qtbot, editor = editor - TIMEOUT = 500 + # Set the base text + editor.set_text(base_editor_text) - editor.autoreload(True) - editor.preferences["Autoreload: watch imported modules"] = True + # Try commenting and uncommenting a single line + editor.go_to_line(1) + editor.toggle_comment() + assert editor.get_text_with_eol() != base_editor_text + editor.toggle_comment() + assert editor.get_text_with_eol() == base_editor_text - with open("test_nested_top.py", "w") as f: - f.write(code_nested_top) + # Try commenting and uncommenting multiple lines + editor.selectAll() + editor.toggle_comment() + assert editor.get_text_with_eol() != base_editor_text + editor.selectAll() + editor.toggle_comment() + assert editor.get_text_with_eol() == base_editor_text - with open("test_nested_bottom.py", "w") as f: - f.write("") - assert editor.get_text_with_eol() == "" +def test_highlight_current_line(editor): + """ + Make sure the current line can be highlighted without error. + """ + qtbot, editor = editor - editor.load_from_file("test_nested_top.py") - assert len(editor.get_text_with_eol()) > 0 + # Set the base text + editor.set_text(base_editor_text) - # wait for reload. - with qtbot.waitSignal(editor.triggerRerender, timeout=TIMEOUT): - # modify file - NB: separate process is needed to avoid Windows quirks - modify_file(code_nested_bottom, "test_nested_bottom.py") + # Highlight the first line + editor.go_to_line(1) + editor.highlight_current_line() -def test_console(main): +def test_set_remove_breakpoints(editor): + """ + Make sure the breakpoints can be added and removed without error. + """ + qtbot, editor = editor + + # Set the base text + editor.set_text(base_editor_text) + + # Toggle breakpoints and check that they are there + assert not editor.line_has_breakpoint(2) + editor.toggle_breakpoint(2) + assert editor.line_has_breakpoint(2) + editor.toggle_breakpoint(2) + assert not editor.line_has_breakpoint(2) + +def test_search(editor): + """ + Tests the search functionality. + """ + qtbot, editor = editor + + # Set the base text + editor.set_text(base_editor_text) + + # Test with no search match + editor.search_widget.on_search_text_changed("~") + assert editor.search_widget.match_label.text() == "0 matches" + + # Check to see that various search texts change the search controls + editor.search_widget.on_search_text_changed("cq") + assert editor.search_widget.match_label.text() == "1 of 2" + + # Make sure advancing to the next and previous matches works properly + editor.search_widget.find_next() + assert editor.search_widget.match_label.text() == "2 of 2" + editor.search_widget.find_previous() + assert editor.search_widget.match_label.text() == "1 of 2" + + # Make sure the show and hide search works + editor.search_widget.show_search() + assert editor.search_widget.isVisible() + editor.search_widget.hide_search() + assert not editor.search_widget.isVisible() + + # Test hotkeys + qtbot.keyClick(editor, Qt.Key_F, modifier=Qt.ControlModifier) + assert editor.search_widget.isVisible() + qtbot.keyClick(editor, Qt.Key_F3) + qtbot.keyClick(editor, Qt.Key_F3, modifier=Qt.AltModifier) + + +def test_line_number_area(editor): + """ + Tests to make sure the line number area on the left of the editor is working correctly. + """ + qtbot, editor = editor + + # Set the base text + editor.set_text(base_editor_text) + + # Make sure the size hint can be retrieved without error + editor.line_number_area.sizeHint() + + # Try to simulate a mouse click in the line number area + pos = QPoint(10, 10) + qtbot.mouseClick(editor.line_number_area, Qt.LeftButton, pos=pos) + + +def test_console(main): qtbot, win = main console = win.components["console"] @@ -1911,3 +2073,81 @@ def test_viewer_orbit_methods(main): qtbot.mouseRelease(viewer, Qt.RightButton) assert True + + +# @pytest.mark.repeat(1) +def test_editor_autoreload(editor): + + qtbot, editor = editor + + TIMEOUT = 500 + + # start out with autoreload enabled + editor.autoreload(True) + + with open("test.py", "w") as f: + f.write(code) + + assert editor.get_text_with_eol() == "" + + editor.load_from_file("test.py") + assert len(editor.get_text_with_eol()) > 0 + + # wait for reload. + with qtbot.waitSignal(editor.triggerRerender, timeout=TIMEOUT): + # modify file - NB: separate process is needed to avoid Widows quirks + modify_file(code_bigger_object) + + # check that editor has updated file contents + assert code_bigger_object.splitlines()[2] in editor.get_text_with_eol() + + # disable autoreload + editor.autoreload(False) + + # Wait for reload in case it incorrectly happens. A timeout should occur + # instead because a re-render should not be triggered with autoreload + # disabled. + with pytest.raises(pytestqt.exceptions.TimeoutError): + with qtbot.waitSignal(editor.triggerRerender, timeout=TIMEOUT): + # re-write original file contents + modify_file(code) + + # editor should continue showing old contents since autoreload is disabled. + assert code_bigger_object.splitlines()[2] in editor.get_text_with_eol() + + # Saving a file with autoreload disabled should not trigger a rerender. + with pytest.raises(pytestqt.exceptions.TimeoutError): + with qtbot.waitSignal(editor.triggerRerender, timeout=TIMEOUT): + editor.save() + + editor.autoreload(True) + + # Saving a file with autoreload enabled should trigger a rerender. + with qtbot.waitSignal(editor.triggerRerender, timeout=TIMEOUT): + editor.save() + + +# def test_autoreload_nested(editor): + +# qtbot, editor = editor + +# TIMEOUT = 500 + +# editor.autoreload(True) +# editor.preferences["Autoreload: watch imported modules"] = True + +# with open("test_nested_top.py", "w") as f: +# f.write(code_nested_top) + +# with open("test_nested_bottom.py", "w") as f: +# f.write("") + +# assert editor.get_text_with_eol() == "" + +# editor.load_from_file("test_nested_top.py") +# assert len(editor.get_text_with_eol()) > 0 + +# # wait for reload. +# with qtbot.waitSignal(editor.triggerRerender, timeout=TIMEOUT): +# # modify file - NB: separate process is needed to avoid Windows quirks +# modify_file(code_nested_bottom, "test_nested_bottom.py")