From 94ae63edbac44e18c1982f37b29186ec36da3456 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 13 Oct 2025 16:02:40 -0400 Subject: [PATCH 01/24] Implemented a basic working editor, not ready for use yet --- cq_editor/__main__.py | 12 +- cq_editor/widgets/code_editor.py | 314 +++++++++++++++++++++++++++++++ cq_editor/widgets/editor.py | 18 +- cq_editor/widgets/pyhighlight.py | 195 +++++++++++++++++++ 4 files changed, 529 insertions(+), 10 deletions(-) create mode 100644 cq_editor/widgets/code_editor.py create mode 100644 cq_editor/widgets/pyhighlight.py diff --git a/cq_editor/__main__.py b/cq_editor/__main__.py index 2298ea56..4eed1b84 100644 --- a/cq_editor/__main__.py +++ b/cq_editor/__main__.py @@ -18,9 +18,15 @@ 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/widgets/code_editor.py b/cq_editor/widgets/code_editor.py new file mode 100644 index 00000000..4285e2b9 --- /dev/null +++ b/cq_editor/widgets/code_editor.py @@ -0,0 +1,314 @@ +from PyQt5 import QtCore, QtGui, QtWidgets + + +DARK_BLUE = QtGui.QColor(118, 150, 185) + + +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 paintEvent(self, event): + self._code_editor.lineNumberAreaPaintEvent(event) + + +class CodeTextEdit(QtWidgets.QPlainTextEdit): + is_first = False + pressed_keys = list() + + indented = QtCore.pyqtSignal(object) + unindented = QtCore.pyqtSignal(object) + commented = QtCore.pyqtSignal(object) + uncommented = QtCore.pyqtSignal(object) + + def __init__(self): + super(CodeTextEdit, self).__init__() + + self.indented.connect(self.do_indent) + self.unindented.connect(self.undo_indent) + self.commented.connect(self.do_comment) + self.uncommented.connect(self.undo_comment) + + def clear_selection(self): + """ + Clear text selection on cursor + """ + pos = self.textCursor().selectionEnd() + self.textCursor().movePosition(pos) + + 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 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 keyReleaseEvent(self, event): + """ + Extend the key release event to catch key combos + """ + if self.is_first: + self.process_multi_keys(self.pressed_keys) + + self.is_first = False + self.pressed_keys.pop() + super(CodeTextEdit, self).keyReleaseEvent(event) + + def process_multi_keys(self, keys): + """ + Placeholder for processing multiple key combo events + + :param keys: [QtCore.Qt.Key]. key combos + """ + # toggle comments indent event + if keys == [QtCore.Qt.Key_Control, QtCore.Qt.Key_Slash]: + pass + + def do_indent(self, lines): + """ + Indent lines + + :param lines: [int]. line numbers + """ + for line in lines: + self.insert_line_start('\t', line) + + def undo_indent(self, lines): + """ + Un-indent lines + + :param lines: [int]. line numbers + """ + for line in lines: + self.remove_line_start('\t', line) + + def do_comment(self, lines): + """ + Comment out lines + + :param lines: [int]. line numbers + """ + for line in lines: + pass + + def undo_comment(self, lines): + """ + Un-comment lines + + :param lines: [int]. line numbers + """ + for line in lines: + pass + + +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 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._filename = "" + + 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="",): + pass + + def set_color_scheme(self, color_scheme): + # print(color_scheme) + pass + + def set_font(self, new_font): + self.font = new_font + + def toggle_wrap_mode(self, wrap_mode): + # print(wrap_mode) + pass + + def go_to_line(self, line_number): + # print(line_number) + pass + + def set_text(self, new_text): + self.setPlainText(new_text) + + def set_text_from_file(self, file_name): + self._filename = file_name + # Load the text into the text field + pass + + 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(): + number = str(block_number + 1) + painter.setPen(DARK_BLUE) + width = self.line_number_area.width() - 10 + height = self.fontMetrics().height() + painter.drawText(0, int(top), width, height, QtCore.Qt.AlignCenter, 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) diff --git a/cq_editor/widgets/editor.py b/cq_editor/widgets/editor.py index 8c3cd91e..ad035717 100644 --- a/cq_editor/widgets/editor.py +++ b/cq_editor/widgets/editor.py @@ -2,7 +2,9 @@ import spyder.utils.encoding from modulefinder import ModuleFinder -from spyder.plugins.editor.widgets.codeeditor import CodeEditor +from .code_editor import CodeEditor +from .pyhighlight import PythonHighlighter +# from spyder.plugins.editor.widgets.codeeditor import CodeEditor from PyQt5.QtCore import pyqtSignal, QFileSystemWatcher, QTimer, Qt, QEvent from PyQt5.QtWidgets import ( QAction, @@ -75,7 +77,7 @@ def __init__(self, parent=None): 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 +147,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 +181,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/pyhighlight.py b/cq_editor/widgets/pyhighlight.py new file mode 100644 index 00000000..d26e75de --- /dev/null +++ b/cq_editor/widgets/pyhighlight.py @@ -0,0 +1,195 @@ +from PyQt5 import QtCore, QtGui, QtWidgets + + +def format(color, style=''): + """ + Return a QTextCharFormat with the given attributes. + """ + _color = QtGui.QColor() + _color.setNamedColor(color) + + _format = QtGui.QTextCharFormat() + _format.setForeground(_color) + if 'bold' in style: + _format.setFontWeight(QtGui.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('red'), + 'brace': format('darkGray'), + 'defclass': format('black', 'bold'), + 'string': format('magenta'), + 'string2': format('darkMagenta'), + 'comment': format('darkGreen', 'italic'), + 'self': format('black', 'italic'), + 'numbers': format('brown'), +} + + +class PythonHighlighter(QtGui.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 = (QtCore.QRegExp("'''"), 1, STYLES['string2']) + self.tri_double = (QtCore.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 = [(QtCore.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 \ No newline at end of file From 785a7ff2b0a4284c441875989f7433014f4d2b8d Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 14 Oct 2025 17:11:43 -0400 Subject: [PATCH 02/24] Implemented more of the core features of the editor --- cq_editor/preferences.py | 2 +- cq_editor/widgets/code_editor.py | 66 ++++++++++++++++++++++++++------ cq_editor/widgets/editor.py | 6 +-- 3 files changed, 59 insertions(+), 15 deletions(-) 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 index 4285e2b9..9baa9d41 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -1,4 +1,5 @@ from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt5.QtGui import QPalette, QColor DARK_BLUE = QtGui.QColor(118, 150, 185) @@ -199,10 +200,10 @@ def __init__(self, parent=None): 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.cursorPositionChanged.connect(self.highlight_current_line) self.update_line_number_area_width(0) - self.highlight_current_line() + # self.highlight_current_line() self.menu = QtWidgets.QMenu() @@ -220,30 +221,73 @@ def setup_editor( font=QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont), language="Python", filename="",): - pass + print("setup_editor called") def set_color_scheme(self, color_scheme): - # print(color_scheme) - pass + """ + 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 def toggle_wrap_mode(self, wrap_mode): - # print(wrap_mode) - pass + print("toggle_wrap_mode called") def go_to_line(self, line_number): - # print(line_number) - pass + print("go_to_line called") def set_text(self, new_text): + """ + Sets the text content of the editor. + :param str new_text: Text to be set in the editor. + """ self.setPlainText(new_text) 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 - pass + 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 @@ -278,7 +322,7 @@ def lineNumberAreaPaintEvent(self, event): painter.setPen(DARK_BLUE) width = self.line_number_area.width() - 10 height = self.fontMetrics().height() - painter.drawText(0, int(top), width, height, QtCore.Qt.AlignCenter, number) + painter.drawText(0, int(top), width, height, QtCore.Qt.AlignRight, number) block = block.next() top = bottom diff --git a/cq_editor/widgets/editor.py b/cq_editor/widgets/editor.py index ad035717..c1d02f89 100644 --- a/cq_editor/widgets/editor.py +++ b/cq_editor/widgets/editor.py @@ -1,5 +1,5 @@ import os -import spyder.utils.encoding +# import spyder.utils.encoding from modulefinder import ModuleFinder from .code_editor import CodeEditor @@ -54,8 +54,8 @@ 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}, ], From fc7c000c67cef1bfcf58a11a4859430b449f3839 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 15 Oct 2025 09:18:31 -0400 Subject: [PATCH 03/24] Fix two errors showing up in the console --- cq_editor/widgets/code_editor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index 9baa9d41..089622a5 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -120,7 +120,8 @@ def keyReleaseEvent(self, event): self.process_multi_keys(self.pressed_keys) self.is_first = False - self.pressed_keys.pop() + if len(self.pressed_keys) > 0: + self.pressed_keys.pop() super(CodeTextEdit, self).keyReleaseEvent(event) def process_multi_keys(self, keys): @@ -262,6 +263,9 @@ def toggle_wrap_mode(self, wrap_mode): def go_to_line(self, line_number): print("go_to_line called") + def toggle_comment(self): + print("toggle_comment called") + def set_text(self, new_text): """ Sets the text content of the editor. From b1995cf3ba1da463348c11442918a524ea17fe3f Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 15 Oct 2025 11:06:17 -0400 Subject: [PATCH 04/24] Lint fixes --- cq_editor/__main__.py | 1 + cq_editor/widgets/code_editor.py | 30 +++---- cq_editor/widgets/editor.py | 2 + cq_editor/widgets/pyhighlight.py | 149 +++++++++++++++++++++---------- 4 files changed, 118 insertions(+), 64 deletions(-) diff --git a/cq_editor/__main__.py b/cq_editor/__main__.py index 4eed1b84..c56f87c8 100644 --- a/cq_editor/__main__.py +++ b/cq_editor/__main__.py @@ -26,6 +26,7 @@ def main(): app.exec_() except Exception as e: import traceback + traceback.print_exc() diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index 089622a5..fd9b2a89 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -69,8 +69,7 @@ def remove_line_start(self, string, line_number): :param string: str. string pattern to remove :param line_number: int. line number """ - cursor = QtGui.QTextCursor( - self.document().findBlockByLineNumber(line_number)) + cursor = QtGui.QTextCursor(self.document().findBlockByLineNumber(line_number)) cursor.select(QtGui.QTextCursor.LineUnderCursor) text = cursor.selectedText() if text.startswith(string): @@ -84,8 +83,7 @@ def insert_line_start(self, string, line_number): :param string: str. string pattern to insert :param line_number: int. line number """ - cursor = QtGui.QTextCursor( - self.document().findBlockByLineNumber(line_number)) + cursor = QtGui.QTextCursor(self.document().findBlockByLineNumber(line_number)) self.setTextCursor(cursor) self.textCursor().insertText(string) @@ -98,15 +96,14 @@ def keyPressEvent(self, event): start_line, end_line = self.get_selection_range() # indent event - if event.key() == QtCore.Qt.Key_Tab and \ - (end_line - start_line): - lines = range(start_line, end_line+1) + if 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) + lines = range(start_line, end_line + 1) self.unindented.emit(lines) return @@ -141,7 +138,7 @@ def do_indent(self, lines): :param lines: [int]. line numbers """ for line in lines: - self.insert_line_start('\t', line) + self.insert_line_start("\t", line) def undo_indent(self, lines): """ @@ -150,7 +147,7 @@ def undo_indent(self, lines): :param lines: [int]. line numbers """ for line in lines: - self.remove_line_start('\t', line) + self.remove_line_start("\t", line) def do_comment(self, lines): """ @@ -197,7 +194,7 @@ def __init__(self, parent=None): self.setFont(self.font) self.tab_size = 4 - self.setTabStopWidth(self.tab_size * self.fontMetrics().width(' ')) + 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) @@ -221,7 +218,8 @@ def setup_editor( show_blanks=True, font=QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont), language="Python", - filename="",): + filename="", + ): print("setup_editor called") def set_color_scheme(self, color_scheme): @@ -282,7 +280,7 @@ def set_text_from_file(self, file_name): self._filename = file_name # Load the text into the text field - with open(file_name, 'r', encoding="utf-8") as file: + with open(file_name, "r", encoding="utf-8") as file: file_content = file.read() self.setPlainText(file_content) @@ -300,7 +298,7 @@ def line_number_area_width(self): max_num *= 0.1 digits += 1 - space = 30 + self.fontMetrics().width('9') * digits + space = 30 + self.fontMetrics().width("9") * digits return space def resizeEvent(self, e): @@ -326,7 +324,9 @@ def lineNumberAreaPaintEvent(self, event): 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) + painter.drawText( + 0, int(top), width, height, QtCore.Qt.AlignRight, number + ) block = block.next() top = bottom diff --git a/cq_editor/widgets/editor.py b/cq_editor/widgets/editor.py index c1d02f89..9263c35b 100644 --- a/cq_editor/widgets/editor.py +++ b/cq_editor/widgets/editor.py @@ -1,9 +1,11 @@ import os + # import spyder.utils.encoding from modulefinder import ModuleFinder from .code_editor import CodeEditor from .pyhighlight import PythonHighlighter + # from spyder.plugins.editor.widgets.codeeditor import CodeEditor from PyQt5.QtCore import pyqtSignal, QFileSystemWatcher, QTimer, Qt, QEvent from PyQt5.QtWidgets import ( diff --git a/cq_editor/widgets/pyhighlight.py b/cq_editor/widgets/pyhighlight.py index d26e75de..826ad80e 100644 --- a/cq_editor/widgets/pyhighlight.py +++ b/cq_editor/widgets/pyhighlight.py @@ -1,7 +1,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets -def format(color, style=''): +def format(color, style=""): """ Return a QTextCharFormat with the given attributes. """ @@ -10,9 +10,9 @@ def format(color, style=''): _format = QtGui.QTextCharFormat() _format.setForeground(_color) - if 'bold' in style: + if "bold" in style: _format.setFontWeight(QtGui.QFont.Bold) - if 'italic' in style: + if "italic" in style: _format.setFontItalic(True) return _format @@ -20,15 +20,15 @@ def format(color, style=''): # Syntax styles that can be shared by all languages STYLES = { - 'keyword': format('blue'), - 'operator': format('red'), - 'brace': format('darkGray'), - 'defclass': format('black', 'bold'), - 'string': format('magenta'), - 'string2': format('darkMagenta'), - 'comment': format('darkGreen', 'italic'), - 'self': format('black', 'italic'), - 'numbers': format('brown'), + "keyword": format("blue"), + "operator": format("red"), + "brace": format("darkGray"), + "defclass": format("black", "bold"), + "string": format("magenta"), + "string2": format("darkMagenta"), + "comment": format("darkGreen", "italic"), + "self": format("black", "italic"), + "numbers": format("brown"), } @@ -36,78 +36,126 @@ class PythonHighlighter(QtGui.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', + "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 = (QtCore.QRegExp("'''"), 1, STYLES['string2']) - self.tri_double = (QtCore.QRegExp('"""'), 2, STYLES['string2']) + self.tri_single = (QtCore.QRegExp("'''"), 1, STYLES["string2"]) + self.tri_double = (QtCore.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] + 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']), - + (r"\bself\b", 0, STYLES["self"]), # 'def' followed by an identifier - (r'\bdef\b\s*(\w+)', 1, STYLES['defclass']), + (r"\bdef\b\s*(\w+)", 1, STYLES["defclass"]), # 'class' followed by an identifier - (r'\bclass\b\s*(\w+)', 1, STYLES['defclass']), - + (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']), - + (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']), + (r'"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES["string"]), # Single-quoted string, possibly containing escape sequences - (r"'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES['string']), - + (r"'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES["string"]), # From '#' until a newline - (r'#[^\n]*', 0, STYLES['comment']), + (r"#[^\n]*", 0, STYLES["comment"]), ] # Build a QRegExp for each pattern - self.rules = [(QtCore.QRegExp(pat), index, fmt) - for (pat, index, fmt) in rules] + self.rules = [(QtCore.QRegExp(pat), index, fmt) for (pat, index, fmt) in rules] def highlightBlock(self, text): """ @@ -121,7 +169,10 @@ def highlightBlock(self, text): # 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"'[^'\\]*(\\.[^'\\]*)*'"]: + 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) @@ -192,4 +243,4 @@ def match_multiline(self, text, delimiter, in_state, style): if self.currentBlockState() == in_state: return True else: - return False \ No newline at end of file + return False From e8a2e2a293c68bd8d25eca373cbc66c666cd25aa Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Fri, 17 Oct 2025 18:17:00 -0400 Subject: [PATCH 05/24] Implemented the ability to toggle comments --- cq_editor/widgets/code_editor.py | 147 +++++++++++++++++++++---------- cq_editor/widgets/pyhighlight.py | 6 +- 2 files changed, 103 insertions(+), 50 deletions(-) diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index fd9b2a89..cdafe3e2 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -1,3 +1,4 @@ +import os from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtGui import QPalette, QColor @@ -23,16 +24,12 @@ class CodeTextEdit(QtWidgets.QPlainTextEdit): indented = QtCore.pyqtSignal(object) unindented = QtCore.pyqtSignal(object) - commented = QtCore.pyqtSignal(object) - uncommented = QtCore.pyqtSignal(object) def __init__(self): super(CodeTextEdit, self).__init__() self.indented.connect(self.do_indent) self.unindented.connect(self.undo_indent) - self.commented.connect(self.do_comment) - self.uncommented.connect(self.undo_comment) def clear_selection(self): """ @@ -109,28 +106,6 @@ def keyPressEvent(self, event): super(CodeTextEdit, self).keyPressEvent(event) - def keyReleaseEvent(self, event): - """ - Extend the key release event to catch key combos - """ - if self.is_first: - self.process_multi_keys(self.pressed_keys) - - self.is_first = False - if len(self.pressed_keys) > 0: - self.pressed_keys.pop() - super(CodeTextEdit, self).keyReleaseEvent(event) - - def process_multi_keys(self, keys): - """ - Placeholder for processing multiple key combo events - - :param keys: [QtCore.Qt.Key]. key combos - """ - # toggle comments indent event - if keys == [QtCore.Qt.Key_Control, QtCore.Qt.Key_Slash]: - pass - def do_indent(self, lines): """ Indent lines @@ -149,24 +124,6 @@ def undo_indent(self, lines): for line in lines: self.remove_line_start("\t", line) - def do_comment(self, lines): - """ - Comment out lines - - :param lines: [int]. line numbers - """ - for line in lines: - pass - - def undo_comment(self, lines): - """ - Un-comment lines - - :param lines: [int]. line numbers - """ - for line in lines: - pass - class EdgeLine(QtWidgets.QWidget): edge_line = None @@ -256,13 +213,109 @@ def set_font(self, new_font): self.font = new_font def toggle_wrap_mode(self, wrap_mode): - print("toggle_wrap_mode called") + self.setLineWrapMode(wrap_mode) def go_to_line(self, line_number): - print("go_to_line called") + """ + 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()) + + def toggle_comment_single_line(self, line_text, cursor, pos): + """ + Adds the comment character (#) and a space at the beginning of a line, + or removes them, if needed. + """ + + # Move right by pos characters to the position before text starts + cursor.movePosition(QtGui.QTextCursor.Right, QtGui.QTextCursor.MoveAnchor, pos) + + # Toggle the comment character on/off + if line_text[pos] != "#": + cursor.insertText("# ") + else: + # Remove the comment character + cursor.movePosition( + QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, 1 + ) + cursor.removeSelectedText() + + # Also remove an extra space if there is one + if line_text[pos + 1] == " ": + cursor.movePosition( + QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, 1 + ) + cursor.removeSelectedText() def toggle_comment(self): - print("toggle_comment called") + """ + 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 + + # Toggle the comment + self.toggle_comment_single_line(line_text, cursor, pos) + 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 + selected_text = cursor.selectedText() + for line_text in selected_text.split(os.linesep): + # 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 + + # 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): + # Set the cursor to the current line number + block = self.document().findBlockByNumber(i) + cursor.setPosition(block.position()) + + # Toggle the comment for the current line + self.toggle_comment_single_line(line_text, cursor, pos) def set_text(self, new_text): """ diff --git a/cq_editor/widgets/pyhighlight.py b/cq_editor/widgets/pyhighlight.py index 826ad80e..0036a9b3 100644 --- a/cq_editor/widgets/pyhighlight.py +++ b/cq_editor/widgets/pyhighlight.py @@ -21,14 +21,14 @@ def format(color, style=""): # Syntax styles that can be shared by all languages STYLES = { "keyword": format("blue"), - "operator": format("red"), + "operator": format("gray"), "brace": format("darkGray"), "defclass": format("black", "bold"), - "string": format("magenta"), + "string": format("orange"), "string2": format("darkMagenta"), "comment": format("darkGreen", "italic"), "self": format("black", "italic"), - "numbers": format("brown"), + "numbers": format("magenta"), } From 26e99514a3f25fca411b9cc8617041765b678271 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 22 Oct 2025 07:44:28 -0400 Subject: [PATCH 06/24] Trying to get blocks with mixed comments and non-comments to work --- cq_editor/widgets/code_editor.py | 68 ++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index cdafe3e2..326892fe 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -226,30 +226,31 @@ def go_to_line(self, line_number): block = self.document().findBlockByNumber(line_number - 1) cursor.setPosition(block.position()) - def toggle_comment_single_line(self, line_text, cursor, pos): + def toggle_comment_single_line(self, cursor, left_pos): """ Adds the comment character (#) and a space at the beginning of a line, or removes them, if needed. """ # 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.MoveAnchor, left_pos) + cursor.movePosition(QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, 1) # Toggle the comment character on/off - if line_text[pos] != "#": + if cursor.selectedText() != "#": + cursor.movePosition(QtGui.QTextCursor.Left, QtGui.QTextCursor.MoveAnchor, 1) cursor.insertText("# ") else: # Remove the comment character + if cursor.selectedText() == "#": + cursor.removeSelectedText() + cursor.movePosition( QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, 1 ) - cursor.removeSelectedText() # Also remove an extra space if there is one - if line_text[pos + 1] == " ": - cursor.movePosition( - QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, 1 - ) + if cursor.selectedText() == " ": cursor.removeSelectedText() def toggle_comment(self): @@ -275,8 +276,22 @@ def toggle_comment(self): while pos < len(line_text) and line_text[pos].isspace(): pos += 1 - # Toggle the comment - self.toggle_comment_single_line(line_text, cursor, pos) + # 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] @@ -294,6 +309,8 @@ def toggle_comment(self): # Find the left-most position to put the comment at leftmost_pos = 99999 selected_text = cursor.selectedText() + comment_line_found = False # When commenting blocks, if comments and non-comment lines are mixed, comment everything + non_comment_line_found = False # When commenting blocks, if comments and non-comment lines are mixed, comment everything for line_text in selected_text.split(os.linesep): # Skip blank lines if line_text == "": @@ -308,14 +325,43 @@ def toggle_comment(self): if pos < leftmost_pos: leftmost_pos = pos + # Helps track whether or not the block should be commented or uncommented + if line_text.lstrip()[0] == "#" and line_text != "": + comment_line_found = True + elif line_text.lstrip()[0] != "#" and line_text != "": + non_comment_line_found = True + # 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()) + # See if we need to comment the whole block + if comment_line_found and non_comment_line_found: + # Insert the comment characters + cursor.movePosition(QtGui.QTextCursor.Left, QtGui.QTextCursor.MoveAnchor, 1) + 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("# ") + # Toggle the comment for the current line - self.toggle_comment_single_line(line_text, cursor, pos) + # self.toggle_comment_single_line(cursor, leftmost_pos) def set_text(self, new_text): """ From 3688a72773753dc079e4c0f5de38a4be5757a101 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Thu, 23 Oct 2025 08:52:55 -0400 Subject: [PATCH 07/24] Improved block commenting and un-commenting --- cq_editor/widgets/code_editor.py | 70 ++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index 326892fe..6ffc9e11 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -233,7 +233,9 @@ def toggle_comment_single_line(self, cursor, left_pos): """ # Move right by pos characters to the position before text starts - cursor.movePosition(QtGui.QTextCursor.Right, QtGui.QTextCursor.MoveAnchor, left_pos) + cursor.movePosition( + QtGui.QTextCursor.Right, QtGui.QTextCursor.MoveAnchor, left_pos + ) cursor.movePosition(QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, 1) # Toggle the comment character on/off @@ -277,26 +279,33 @@ def toggle_comment(self): 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) + 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) + 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.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 @@ -308,13 +317,20 @@ def toggle_comment(self): # Find the left-most position to put the comment at leftmost_pos = 99999 - selected_text = cursor.selectedText() - comment_line_found = False # When commenting blocks, if comments and non-comment lines are mixed, comment everything - non_comment_line_found = False # When commenting blocks, if comments and non-comment lines are mixed, comment everything - for line_text in selected_text.split(os.linesep): - # Skip blank lines - if line_text == "": - return + comment_line_found = False + non_comment_line_found = False + # 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() + + if line_text.strip()[0] == "#": + comment_line_found = True + else: + non_comment_line_found = True # Find the first non-whitespace character position pos = 0 @@ -325,12 +341,6 @@ def toggle_comment(self): if pos < leftmost_pos: leftmost_pos = pos - # Helps track whether or not the block should be commented or uncommented - if line_text.lstrip()[0] == "#" and line_text != "": - comment_line_found = True - elif line_text.lstrip()[0] != "#" and line_text != "": - non_comment_line_found = True - # 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 @@ -340,29 +350,35 @@ def toggle_comment(self): # See if we need to comment the whole block if comment_line_found and non_comment_line_found: # Insert the comment characters - cursor.movePosition(QtGui.QTextCursor.Left, QtGui.QTextCursor.MoveAnchor, 1) 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) + 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) + 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.movePosition( + QtGui.QTextCursor.Left, QtGui.QTextCursor.MoveAnchor, 1 + ) cursor.insertText("# ") - # Toggle the comment for the current line - # self.toggle_comment_single_line(cursor, leftmost_pos) - def set_text(self, new_text): """ Sets the text content of the editor. From 7a23f78ff17bc1274ba596ba7b0ca64771990d2a Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Thu, 23 Oct 2025 09:42:50 -0400 Subject: [PATCH 08/24] Fixed fonts and some related version pins --- conda/meta.yaml | 8 ++++---- cq_editor/icons.py | 38 +++++++++++++++++++---------------- cq_editor/widgets/debugger.py | 2 +- cq_editor/widgets/editor.py | 3 +-- cq_editor/widgets/viewer.py | 8 ++++---- cqgui_env.yml | 4 ++-- pyproject.toml | 6 +++--- 7 files changed, 36 insertions(+), 33 deletions(-) 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/icons.py b/cq_editor/icons.py index c383d845..0607c6c8 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,11 @@ ] }, ), - "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",), {}), } diff --git a/cq_editor/widgets/debugger.py b/cq_editor/widgets/debugger.py index b911f55d..4142dc9d 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 diff --git a/cq_editor/widgets/editor.py b/cq_editor/widgets/editor.py index 9263c35b..d73a6e0e 100644 --- a/cq_editor/widgets/editor.py +++ b/cq_editor/widgets/editor.py @@ -6,7 +6,6 @@ from .code_editor import CodeEditor from .pyhighlight import PythonHighlighter -# from spyder.plugins.editor.widgets.codeeditor import CodeEditor from PyQt5.QtCore import pyqtSignal, QFileSystemWatcher, QTimer, Qt, QEvent from PyQt5.QtWidgets import ( QAction, @@ -65,7 +64,7 @@ class Editor(CodeEditor, ComponentMixin): 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 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..a1a04967 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,13 @@ version = "0.6.dev0" dependencies = [ "cadquery", "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" } ] From ffaf894c66b9d2530b0d4a6b5879bcfe04992855 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Thu, 23 Oct 2025 09:48:53 -0400 Subject: [PATCH 09/24] Tweaked the clear log and clear console icons --- cq_editor/icons.py | 2 ++ cq_editor/widgets/console.py | 2 +- cq_editor/widgets/log.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cq_editor/icons.py b/cq_editor/icons.py index 0607c6c8..6b6440b1 100644 --- a/cq_editor/icons.py +++ b/cq_editor/icons.py @@ -76,6 +76,8 @@ "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/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/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), ] } From 037589b13f1cfac945e272df4c902899dd38089f Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Thu, 23 Oct 2025 19:10:14 -0400 Subject: [PATCH 10/24] Started adding editor debugger and fixed bug with Editor.set_text --- cq_editor/widgets/code_editor.py | 10 ++++++++++ cq_editor/widgets/debugger.py | 7 +++++-- cq_editor/widgets/editor.py | 14 ++++++++++++++ tests/test_app.py | 2 +- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index 6ffc9e11..f39d398f 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -215,6 +215,15 @@ def set_font(self, 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) + def go_to_line(self, line_number): """ Set the text cursor at a specific line number. @@ -385,6 +394,7 @@ def set_text(self, new_text): :param str new_text: Text to be set in the editor. """ self.setPlainText(new_text) + self.document().setModified(True) def set_text_from_file(self, file_name): """ diff --git a/cq_editor/widgets/debugger.py b/cq_editor/widgets/debugger.py index 4142dc9d..7c529c82 100644 --- a/cq_editor/widgets/debugger.py +++ b/cq_editor/widgets/debugger.py @@ -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 d73a6e0e..c076229f 100644 --- a/cq_editor/widgets/editor.py +++ b/cq_editor/widgets/editor.py @@ -30,6 +30,18 @@ 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" @@ -74,6 +86,8 @@ def __init__(self, parent=None): self._watched_file = None + self.debugger = EditorDebugger() + super(Editor, self).__init__(parent) ComponentMixin.__init__(self) diff --git a/tests/test_app.py b/tests/test_app.py index c8d0a8c3..e0119dfb 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 == [] From 74790ecac6fc44c6a4b4a87637dab2ffce33a057 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 27 Oct 2025 10:39:09 -0400 Subject: [PATCH 11/24] Fixed bug in set_text and fixed test order errors --- cq_editor/widgets/code_editor.py | 9 ++ tests/test_app.py | 180 +++++++++++++++---------------- 2 files changed, 99 insertions(+), 90 deletions(-) diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index f39d398f..37dc4f39 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -223,6 +223,7 @@ def set_cursor_position(self, position): cursor = self.textCursor() cursor.setPosition(position) + self.setTextCursor(cursor) def go_to_line(self, line_number): """ @@ -393,7 +394,15 @@ 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): diff --git a/tests/test_app.py b/tests/test_app.py index e0119dfb..28d1f3c8 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 @@ -700,86 +700,7 @@ def filename2(*args, **kwargs): editor.restoreComponentState(settings) -@pytest.mark.repeat(1) -def test_editor_autoreload(monkeypatch, 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") - - def test_console(main): - qtbot, win = main console = win.components["console"] @@ -834,6 +755,7 @@ def test_module_import(main): # run the code importing this module editor.set_text(code_import) + qtbot.wait(1000) debugger._actions["Run"][0].triggered.emit() # verify that no exception was generated @@ -1911,3 +1833,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") From aee0f73e6ab63bb4e5bfe52c18f32c5d1673aa30 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 27 Oct 2025 11:33:28 -0400 Subject: [PATCH 12/24] Omitting __main__.py from test coverage requirements --- .coveragerc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.coveragerc b/.coveragerc index 9aa183ac..c3f90731 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,6 +2,8 @@ timid = True branch = True source = src +omit = + cq_editor/__main__.py [report] exclude_lines = From 7ae3a54e2e856b16de36f00d2310d4928d170be8 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 27 Oct 2025 14:53:07 -0400 Subject: [PATCH 13/24] Improved test coverage and fixed things that came up --- .coveragerc | 1 + cq_editor/widgets/code_editor.py | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.coveragerc b/.coveragerc index c3f90731..11476124 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,6 +4,7 @@ branch = True source = src omit = cq_editor/__main__.py + cq_editor/widgets/pyhighlight.py [report] exclude_lines = diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index 37dc4f39..4e3f4069 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -93,11 +93,14 @@ def keyPressEvent(self, event): start_line, end_line = self.get_selection_range() # indent event - if event.key() == QtCore.Qt.Key_Tab and (end_line - start_line): + 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) @@ -113,7 +116,7 @@ def do_indent(self, lines): :param lines: [int]. line numbers """ for line in lines: - self.insert_line_start("\t", line) + self.insert_line_start(" ", line) def undo_indent(self, lines): """ @@ -122,7 +125,13 @@ def undo_indent(self, lines): :param lines: [int]. line numbers """ for line in lines: - self.remove_line_start("\t", line) + 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 EdgeLine(QtWidgets.QWidget): From 50892c9b5ec2905a69cc8c459929c70fc52a2a56 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 27 Oct 2025 16:33:15 -0400 Subject: [PATCH 14/24] Added editor test file --- tests/test_editor.py | 137 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/test_editor.py diff --git a/tests/test_editor.py b/tests/test_editor.py new file mode 100644 index 00000000..32b5e66d --- /dev/null +++ b/tests/test_editor.py @@ -0,0 +1,137 @@ +import pytest +from PyQt5.QtCore import QSize, Qt +from cq_editor.__main__ import MainWindow + +base_editor_text = """import cadquery as cq +result = cq.Workplane().box(10, 10, 10) +""" + + +@pytest.fixture +def main(qtbot, mocker): + win = MainWindow() + win.show() + + qtbot.addWidget(win) + + editor = win.components["editor"] + + return qtbot, win + + +def test_size_hint(main): + """ + Tests the ability to get the size hit from the code editor widget. + """ + qtbot, win = main + + editor = win.components["editor"] + size_hint = editor.sizeHint() + + assert size_hint == QSize(256, 192) + + +def test_clear_selection(main): + """ + Tests the ability to clear selected text. + """ + qtbot, win = main + + editor = win.components["editor"] + + # Set a block of text and make sure it is visible + editor.set_text(base_editor_text) + editor.document().setModified(False) + qtbot.wait(100) + 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) + qtbot.wait(100) + assert editor.get_text_with_eol() == "" + + +def test_get_selection_range(main): + qtbot, win = main + + editor = win.components["editor"] + + # Set a block of text and make sure it is visible + editor.set_text(base_editor_text) + editor.document().setModified(False) + qtbot.wait(100) + assert editor.get_text_with_eol() == base_editor_text + + # Select all the text and get the selection range + editor.selectAll() + selection_range = editor.get_selection_range() + + +def test_insert_remove_line_start(main): + """ + Tests the ability to remove and insert characters from/to the beginning of a line. + """ + qtbot, win = main + + editor = win.components["editor"] + + # 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) + qtbot.wait(100) + assert editor.get_text_with_eol() == "# " + base_editor_text + + # Remove the comment character from the line + editor.remove_line_start("# ", 0) + editor.document().setModified(False) + qtbot.wait(100) + assert editor.get_text_with_eol() == base_editor_text + + +def test_indent_unindent(main): + """ + Check to make sure that indent and un-indent work properly. + """ + qtbot, win = main + + editor = win.components["editor"] + + # Set the base text + editor.set_text(base_editor_text) + qtbot.wait(100) + + # Indent the text and check + editor.selectAll() + qtbot.keyClick(editor, Qt.Key_Tab) + editor.document().setModified(False) + qtbot.wait(250) + 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) + qtbot.wait(2500) + 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) + qtbot.wait(250) + 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) + qtbot.wait(250) + assert editor.get_text_with_eol() == base_editor_text From c9c14b0a38132f7779b11c9a0a56bb74ac9d625f Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 27 Oct 2025 20:54:55 -0400 Subject: [PATCH 15/24] Re-ordered test files --- tests/{test_editor.py => test_1_editor.py} | 0 tests/test_app.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename tests/{test_editor.py => test_1_editor.py} (100%) diff --git a/tests/test_editor.py b/tests/test_1_editor.py similarity index 100% rename from tests/test_editor.py rename to tests/test_1_editor.py diff --git a/tests/test_app.py b/tests/test_app.py index 28d1f3c8..a69fd675 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -755,7 +755,7 @@ def test_module_import(main): # run the code importing this module editor.set_text(code_import) - qtbot.wait(1000) + qtbot.wait(250) debugger._actions["Run"][0].triggered.emit() # verify that no exception was generated From 0aab23cae3604dffb0e54eb505a3c137e2b57b4b Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 28 Oct 2025 06:44:10 -0400 Subject: [PATCH 16/24] Merged tests back together and removed some waits that were causing problems --- tests/test_1_editor.py | 137 ----------------------------------------- tests/test_app.py | 108 +++++++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 138 deletions(-) delete mode 100644 tests/test_1_editor.py diff --git a/tests/test_1_editor.py b/tests/test_1_editor.py deleted file mode 100644 index 32b5e66d..00000000 --- a/tests/test_1_editor.py +++ /dev/null @@ -1,137 +0,0 @@ -import pytest -from PyQt5.QtCore import QSize, Qt -from cq_editor.__main__ import MainWindow - -base_editor_text = """import cadquery as cq -result = cq.Workplane().box(10, 10, 10) -""" - - -@pytest.fixture -def main(qtbot, mocker): - win = MainWindow() - win.show() - - qtbot.addWidget(win) - - editor = win.components["editor"] - - return qtbot, win - - -def test_size_hint(main): - """ - Tests the ability to get the size hit from the code editor widget. - """ - qtbot, win = main - - editor = win.components["editor"] - size_hint = editor.sizeHint() - - assert size_hint == QSize(256, 192) - - -def test_clear_selection(main): - """ - Tests the ability to clear selected text. - """ - qtbot, win = main - - editor = win.components["editor"] - - # Set a block of text and make sure it is visible - editor.set_text(base_editor_text) - editor.document().setModified(False) - qtbot.wait(100) - 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) - qtbot.wait(100) - assert editor.get_text_with_eol() == "" - - -def test_get_selection_range(main): - qtbot, win = main - - editor = win.components["editor"] - - # Set a block of text and make sure it is visible - editor.set_text(base_editor_text) - editor.document().setModified(False) - qtbot.wait(100) - assert editor.get_text_with_eol() == base_editor_text - - # Select all the text and get the selection range - editor.selectAll() - selection_range = editor.get_selection_range() - - -def test_insert_remove_line_start(main): - """ - Tests the ability to remove and insert characters from/to the beginning of a line. - """ - qtbot, win = main - - editor = win.components["editor"] - - # 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) - qtbot.wait(100) - assert editor.get_text_with_eol() == "# " + base_editor_text - - # Remove the comment character from the line - editor.remove_line_start("# ", 0) - editor.document().setModified(False) - qtbot.wait(100) - assert editor.get_text_with_eol() == base_editor_text - - -def test_indent_unindent(main): - """ - Check to make sure that indent and un-indent work properly. - """ - qtbot, win = main - - editor = win.components["editor"] - - # Set the base text - editor.set_text(base_editor_text) - qtbot.wait(100) - - # Indent the text and check - editor.selectAll() - qtbot.keyClick(editor, Qt.Key_Tab) - editor.document().setModified(False) - qtbot.wait(250) - 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) - qtbot.wait(2500) - 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) - qtbot.wait(250) - 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) - qtbot.wait(250) - assert editor.get_text_with_eol() == base_editor_text diff --git a/tests/test_app.py b/tests/test_app.py index a69fd675..ef729db5 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 @@ -557,6 +557,9 @@ 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,6 +703,109 @@ def filename2(*args, **kwargs): editor.restoreComponentState(settings) +def test_size_hint(editor): + """ + Tests the ability to get the size hit from the code editor widget. + """ + qtbot, editor = editor + + size_hint = editor.sizeHint() + + assert size_hint == QSize(256, 192) + + +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() == "" + + +def test_get_selection_range(editor): + """ + Tests the ability to get the lines that are selected. + """ + 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 + + # Select all the text and get the selection range + editor.selectAll() + selection_range = editor.get_selection_range() + assert selection_range == (0, 2) + + +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 + + # 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 + + # 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 + + +def test_indent_unindent(editor): + """ + Check to make sure that indent and un-indent work properly. + """ + qtbot, editor = editor + + # 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 + + def test_console(main): qtbot, win = main From 269c40731a0b47c5c63791a8da26959e028ea6a3 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 28 Oct 2025 06:45:41 -0400 Subject: [PATCH 17/24] Lint fix --- tests/test_app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_app.py b/tests/test_app.py index ef729db5..20addf5c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -561,6 +561,7 @@ def check_no_error_occured(): result = cq.Workplane().box(10, 10, 10) """ + def test_traceback(main): # store the tracing function From fc76611695d775bdc7362154acd8b03e14d008ba Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 28 Oct 2025 09:20:34 -0400 Subject: [PATCH 18/24] Added more tests and fixed bug with block un-commenting --- cq_editor/widgets/code_editor.py | 12 ++++++ tests/test_app.py | 70 +++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index 4e3f4069..157c0eaf 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -243,7 +243,9 @@ def go_to_line(self, line_number): # Line numbers start at 0 block = self.document().findBlockByNumber(line_number - 1) + cursor.setPosition(block.position()) + self.setTextCursor(cursor) def toggle_comment_single_line(self, cursor, left_pos): """ @@ -338,6 +340,7 @@ def toggle_comment(self): 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 @@ -346,6 +349,11 @@ def toggle_comment(self): 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: @@ -362,6 +370,10 @@ def toggle_comment(self): # 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()) diff --git a/tests/test_app.py b/tests/test_app.py index 20addf5c..aa592bce 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -807,6 +807,75 @@ def test_indent_unindent(editor): 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 + + 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_toggle_comment(editor): + """ + Tests to make sure that lines can be commented/uncommented. + """ + qtbot, editor = editor + + # Set the base text + editor.set_text(base_editor_text) + + # 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 + + # 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 + + +def test_highlight_current_line(editor): + """ + Make sure the current line can be highlighted without error. + """ + qtbot, editor = editor + + # Set the base text + editor.set_text(base_editor_text) + + # Highlight the first line + editor.go_to_line(1) + editor.highlight_current_line() + + def test_console(main): qtbot, win = main @@ -862,7 +931,6 @@ def test_module_import(main): # run the code importing this module editor.set_text(code_import) - qtbot.wait(250) debugger._actions["Run"][0].triggered.emit() # verify that no exception was generated From c25b23f834507c6e99bfb74df027d7345f0ceeac Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 28 Oct 2025 12:33:14 -0400 Subject: [PATCH 19/24] Fixed the font setting method and added the maximum line length indicator --- cq_editor/widgets/code_editor.py | 32 ++++++++++++++++++++++++++++++++ cq_editor/widgets/editor.py | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index 157c0eaf..84d3104c 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -1,3 +1,5 @@ +# 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 @@ -220,6 +222,7 @@ def set_color_scheme(self, color_scheme): 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) @@ -515,3 +518,32 @@ def highlight_current_line(self): selection.cursor.clearSelection() extra_selections.append(selection) self.setExtraSelections(extra_selections) + + 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/editor.py b/cq_editor/widgets/editor.py index c076229f..87a79692 100644 --- a/cq_editor/widgets/editor.py +++ b/cq_editor/widgets/editor.py @@ -70,7 +70,7 @@ class Editor(CodeEditor, ComponentMixin): "values": ["Light", "Dark"], "value": "Light", }, - {"name": "Maximum line length", "type": "int", "value": 88}, + {"name": "Maximum line length", "type": "int", "value": 79}, ], ) From 6ecbc44fd1e9d13cbf70ff0cd4104425d28a5173 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 28 Oct 2025 17:06:58 -0400 Subject: [PATCH 20/24] Added breakpoints back to the code editor --- cq_editor/widgets/code_editor.py | 66 +++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index 84d3104c..a9f92186 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -16,6 +16,40 @@ def __init__(self, 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) @@ -478,7 +512,19 @@ def lineNumberAreaPaintEvent(self, event): while block.isValid() and top <= event.rect().bottom(): if block.isVisible() and bottom >= event.rect().top(): - number = str(block_number + 1) + 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() @@ -519,6 +565,24 @@ def highlight_current_line(self): 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. From a40cb3ab81ee09385f4bd24f409debdb02d96f5e Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 28 Oct 2025 17:07:39 -0400 Subject: [PATCH 21/24] Lint fixes --- cq_editor/widgets/code_editor.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index a9f92186..bfb68b67 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -42,7 +42,9 @@ def get_line_number_from_position(self, pos): 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 + return ( + block_number + 1 + ) # Line numbers start at 0 internally and 1 for the user block = block.next() top = bottom @@ -516,12 +518,19 @@ def lineNumberAreaPaintEvent(self, event): # 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.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) + 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) From 8a5d1f44f27821aab05c9ad191f97e08f0a149e9 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 29 Oct 2025 14:19:17 -0400 Subject: [PATCH 22/24] Fixed a bug with tabbing and tried to increase test coverage a bit --- cq_editor/widgets/code_editor.py | 46 ++++++++++++-------------------- tests/test_app.py | 17 ++++++++++++ 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index bfb68b67..fe425a18 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -153,6 +153,23 @@ def do_indent(self, 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) @@ -286,35 +303,6 @@ def go_to_line(self, line_number): cursor.setPosition(block.position()) self.setTextCursor(cursor) - def toggle_comment_single_line(self, cursor, left_pos): - """ - Adds the comment character (#) and a space at the beginning of a line, - or removes them, if needed. - """ - - # Move right by pos characters to the position before text starts - cursor.movePosition( - QtGui.QTextCursor.Right, QtGui.QTextCursor.MoveAnchor, left_pos - ) - cursor.movePosition(QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, 1) - - # Toggle the comment character on/off - if cursor.selectedText() != "#": - cursor.movePosition(QtGui.QTextCursor.Left, QtGui.QTextCursor.MoveAnchor, 1) - cursor.insertText("# ") - else: - # Remove the comment character - if cursor.selectedText() == "#": - cursor.removeSelectedText() - - cursor.movePosition( - QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, 1 - ) - - # Also remove an extra space if there is one - if cursor.selectedText() == " ": - cursor.removeSelectedText() - def toggle_comment(self): """ High level method to comment or uncomment a single line, diff --git a/tests/test_app.py b/tests/test_app.py index aa592bce..0b4084e4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -876,6 +876,23 @@ def test_highlight_current_line(editor): editor.highlight_current_line() +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_console(main): qtbot, win = main From 7e16db50085b037a48e5e8cb6ed0d704f70b32ef Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 29 Oct 2025 17:34:40 -0400 Subject: [PATCH 23/24] Added search feature in code editor --- cq_editor/main_window.py | 10 ++ cq_editor/widgets/code_editor.py | 284 +++++++++++++++++++++++++++++-- 2 files changed, 280 insertions(+), 14 deletions(-) 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/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index fe425a18..a0771441 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -8,6 +8,235 @@ 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) @@ -56,6 +285,20 @@ 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() @@ -189,20 +432,6 @@ def undo_indent(self, lines): self.setTextCursor(cursor) -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 CodeEditor(CodeTextEdit): def __init__(self, parent=None): super(CodeEditor, self).__init__() @@ -228,8 +457,35 @@ def __init__(self, parent=None): 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, From 982d5593f9ece93e6f4cb2622046386762d017ae Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Thu, 30 Oct 2025 10:19:08 -0400 Subject: [PATCH 24/24] Increasing test coverage --- cq_editor/widgets/code_editor.py | 6 ++- tests/test_app.py | 76 ++++++++++++++++++++++++++------ 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/cq_editor/widgets/code_editor.py b/cq_editor/widgets/code_editor.py index a0771441..98af9cb9 100644 --- a/cq_editor/widgets/code_editor.py +++ b/cq_editor/widgets/code_editor.py @@ -316,8 +316,10 @@ def clear_selection(self): """ Clear text selection on cursor """ - pos = self.textCursor().selectionEnd() - self.textCursor().movePosition(pos) + cursor = self.textCursor() + pos = cursor.selectionEnd() + cursor.movePosition(pos) + self.setTextCursor(cursor) def get_selection_range(self): """ diff --git a/tests/test_app.py b/tests/test_app.py index 0b4084e4..12894dc4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -734,22 +734,12 @@ def test_clear_selection(editor): editor.document().setModified(False) assert editor.get_text_with_eol() == "" - -def test_get_selection_range(editor): - """ - Tests the ability to get the lines that are selected. - """ - qtbot, editor = editor - - # Set a block of text and make sure it is visible + # Test the ability to deselect a selected area editor.set_text(base_editor_text) - editor.document().setModified(False) - assert editor.get_text_with_eol() == base_editor_text - - # Select all the text and get the selection range editor.selectAll() - selection_range = editor.get_selection_range() - assert selection_range == (0, 2) + assert editor.get_selection_range() == (0, 2) + editor.clear_selection() + assert editor.get_selection_range() == (0, 0) def test_insert_remove_line_start(editor): @@ -806,6 +796,11 @@ def test_indent_unindent(editor): 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): """ @@ -893,6 +888,59 @@ def test_set_remove_breakpoints(editor): 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