pyqt-code-editor 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. pyqt_code_editor/__init__.py +5 -0
  2. pyqt_code_editor/_settings.py +232 -0
  3. pyqt_code_editor/app.py +438 -0
  4. pyqt_code_editor/code_editors/__init__.py +1 -0
  5. pyqt_code_editor/code_editors/code_editors.py +33 -0
  6. pyqt_code_editor/code_editors/languages/__init__.py +0 -0
  7. pyqt_code_editor/code_editors/languages/generic.py +11 -0
  8. pyqt_code_editor/code_editors/languages/markdown.py +11 -0
  9. pyqt_code_editor/code_editors/languages/python.py +13 -0
  10. pyqt_code_editor/code_editors/languages/text.py +9 -0
  11. pyqt_code_editor/components/__init__.py +0 -0
  12. pyqt_code_editor/components/editor_panel.py +399 -0
  13. pyqt_code_editor/components/find_in_files.py +199 -0
  14. pyqt_code_editor/components/jupyter_console.py +487 -0
  15. pyqt_code_editor/components/project_explorer.py +515 -0
  16. pyqt_code_editor/components/settings_panel.py +162 -0
  17. pyqt_code_editor/components/sigmund.py +75 -0
  18. pyqt_code_editor/components/workspace_explorer.py +162 -0
  19. pyqt_code_editor/mixins/__init__.py +15 -0
  20. pyqt_code_editor/mixins/auto_pair.py +201 -0
  21. pyqt_code_editor/mixins/base.py +142 -0
  22. pyqt_code_editor/mixins/check.py +94 -0
  23. pyqt_code_editor/mixins/comment.py +91 -0
  24. pyqt_code_editor/mixins/complete.py +482 -0
  25. pyqt_code_editor/mixins/execute.py +182 -0
  26. pyqt_code_editor/mixins/file_link.py +166 -0
  27. pyqt_code_editor/mixins/line_number.py +206 -0
  28. pyqt_code_editor/mixins/python_auto_indent.py +239 -0
  29. pyqt_code_editor/mixins/search_replace.py +396 -0
  30. pyqt_code_editor/mixins/shortcuts.py +228 -0
  31. pyqt_code_editor/mixins/smart_backspace_delete.py +120 -0
  32. pyqt_code_editor/mixins/symbols.py +45 -0
  33. pyqt_code_editor/mixins/theme.py +99 -0
  34. pyqt_code_editor/mixins/widgets/__init__.py +0 -0
  35. pyqt_code_editor/mixins/zoom.py +51 -0
  36. pyqt_code_editor/python_utils.py +936 -0
  37. pyqt_code_editor/signal_router.py +116 -0
  38. pyqt_code_editor/syntax_highlighters/__init__.py +0 -0
  39. pyqt_code_editor/syntax_highlighters/languages/__init__.py +0 -0
  40. pyqt_code_editor/syntax_highlighters/languages/generic.py +86 -0
  41. pyqt_code_editor/syntax_highlighters/languages/python.py +62 -0
  42. pyqt_code_editor/syntax_highlighters/syntax_highlighter.py +26 -0
  43. pyqt_code_editor/themes.py +36 -0
  44. pyqt_code_editor/utils.py +96 -0
  45. pyqt_code_editor/watchdog.py +141 -0
  46. pyqt_code_editor/widgets/__init__.py +6 -0
  47. pyqt_code_editor/widgets/calltip_widget.py +44 -0
  48. pyqt_code_editor/widgets/completion_popup.py +89 -0
  49. pyqt_code_editor/widgets/dock.py +15 -0
  50. pyqt_code_editor/widgets/quick_open_dialog.py +196 -0
  51. pyqt_code_editor/widgets/quick_open_file_dialog.py +32 -0
  52. pyqt_code_editor/widgets/quick_symbol_dialog.py +13 -0
  53. pyqt_code_editor/widgets/search_replace_frame.py +128 -0
  54. pyqt_code_editor/widgets/tab_splitter.py +22 -0
  55. pyqt_code_editor/widgets/tabbed_editor.py +351 -0
  56. pyqt_code_editor/worker/__init__.py +0 -0
  57. pyqt_code_editor/worker/languages/__init__.py +0 -0
  58. pyqt_code_editor/worker/languages/generic.py +15 -0
  59. pyqt_code_editor/worker/languages/python.py +17 -0
  60. pyqt_code_editor/worker/manager.py +113 -0
  61. pyqt_code_editor/worker/process.py +128 -0
  62. pyqt_code_editor/worker/providers/__init__.py +1 -0
  63. pyqt_code_editor/worker/providers/codestral.py +56 -0
  64. pyqt_code_editor/worker/providers/jedi.py +115 -0
  65. pyqt_code_editor/worker/providers/ruff.py +45 -0
  66. pyqt_code_editor/worker/providers/sigmund.py +29 -0
  67. pyqt_code_editor/worker/providers/symbol.py +78 -0
  68. pyqt_code_editor/worker/settings.py +1 -0
  69. pyqt_code_editor-0.0.1.dist-info/METADATA +69 -0
  70. pyqt_code_editor-0.0.1.dist-info/RECORD +73 -0
  71. pyqt_code_editor-0.0.1.dist-info/WHEEL +4 -0
  72. pyqt_code_editor-0.0.1.dist-info/entry_points.txt +4 -0
  73. pyqt_code_editor-0.0.1.dist-info/licenses/COPYING +674 -0
@@ -0,0 +1,75 @@
1
+ from sigmund_qtwidget.sigmund_widget import SigmundWidget
2
+ from ..widgets import Dock
3
+ from .. import watchdog
4
+ import logging
5
+ logging.basicConfig(level=logging.debug)
6
+
7
+
8
+ class EditorWorkspace:
9
+ def __init__(self, editor_panel):
10
+ self._editor_panel = editor_panel
11
+
12
+ @property
13
+ def _editor(self):
14
+ return self._editor_panel.active_editor()
15
+
16
+ def _normalize_line_breaks(self, text):
17
+ """Convert paragraph separators (U+2029) to standard newlines."""
18
+ if text:
19
+ return text.replace(u'\u2029', '\n')
20
+ return text
21
+
22
+ @property
23
+ def content(self):
24
+ text_cursor = self._editor.textCursor()
25
+ if text_cursor.hasSelection():
26
+ return self._normalize_line_breaks(text_cursor.selectedText())
27
+ return self._editor.toPlainText()
28
+
29
+ @property
30
+ def language(self):
31
+ return self._editor.code_editor_language
32
+
33
+ def get(self):
34
+ text_cursor = self._editor.textCursor()
35
+ if text_cursor.hasSelection():
36
+ return self._normalize_line_breaks(text_cursor.selectedText()), self._editor.code_editor_language
37
+ return self._editor.toPlainText(), self._editor.code_editor_language
38
+
39
+ def set(self, content, language):
40
+ text_cursor = self._editor.textCursor()
41
+ if text_cursor.hasSelection():
42
+ text_cursor.insertText(content)
43
+ self._editor.setTextCursor(text_cursor)
44
+ else:
45
+ self._editor.setPlainText(content)
46
+
47
+ def has_changed(self, content, language):
48
+ text_cursor = self._editor.textCursor()
49
+ if text_cursor.hasSelection():
50
+ editor_content = self._normalize_line_breaks(text_cursor.selectedText())
51
+ else:
52
+ editor_content = self._editor.toPlainText()
53
+
54
+ if not content:
55
+ return False
56
+ if content in (editor_content, self.strip_content(editor_content)):
57
+ return False
58
+ return True
59
+
60
+ def strip_content(self, content):
61
+ if content is None:
62
+ return ''
63
+ return content
64
+
65
+
66
+ class Sigmund(Dock):
67
+ def __init__(self, parent, editor_panel):
68
+ super().__init__('Sigmund', parent)
69
+ self.setObjectName("sigmund")
70
+ workspace = EditorWorkspace(editor_panel)
71
+ self.sigmund_widget = SigmundWidget(self)
72
+ self.sigmund_widget.set_workspace_manager(workspace)
73
+ self.sigmund_widget.start_server()
74
+ watchdog.register_subprocess(self.sigmund_widget.server_pid)
75
+ self.setWidget(self.sigmund_widget)
@@ -0,0 +1,162 @@
1
+ from qtpy.QtWidgets import (QDockWidget, QWidget, QVBoxLayout, QHBoxLayout,
2
+ QTableView, QHeaderView, QLineEdit, QLabel,
3
+ QAbstractItemView, QMenu, QAction)
4
+ from qtpy.QtCore import Qt, QSortFilterProxyModel, QAbstractTableModel, QModelIndex
5
+ from qtpy.QtGui import QIcon, QColor, QFont, QBrush
6
+ from ..widgets import Dock
7
+
8
+
9
+ class WorkspaceModel(QAbstractTableModel):
10
+ """Model to display variables in the workspace"""
11
+
12
+ def __init__(self, parent=None):
13
+ super().__init__(parent)
14
+ self._data = {} # Will store the workspace data
15
+ self._headers = ["Name", "Type", "Value"]
16
+ self._var_list = [] # List of variable names for easy access
17
+
18
+ # Define type icons - would be properly set in a real implementation
19
+ self._type_icons = {
20
+ 'int': None,
21
+ 'float': None,
22
+ 'str': None,
23
+ 'list': None,
24
+ 'dict': None,
25
+ 'DataFrame': None
26
+ }
27
+
28
+ def update_data(self, workspace_data):
29
+ """Update the model with new workspace data"""
30
+ self.beginResetModel()
31
+ self._data = workspace_data
32
+ self._var_list = list(workspace_data.keys())
33
+ self.endResetModel()
34
+
35
+ def rowCount(self, parent=QModelIndex()):
36
+ return len(self._var_list)
37
+
38
+ def columnCount(self, parent=QModelIndex()):
39
+ return 3 # Name, Type, Value
40
+
41
+ def data(self, index, role=Qt.DisplayRole):
42
+ if not index.isValid() or index.row() >= len(self._var_list):
43
+ return None
44
+
45
+ var_name = self._var_list[index.row()]
46
+ var_data = self._data[var_name]
47
+
48
+ if role == Qt.DisplayRole:
49
+ if index.column() == 0:
50
+ return var_name
51
+ elif index.column() == 1:
52
+ return var_data['type']
53
+ elif index.column() == 2:
54
+ return var_data['preview']
55
+
56
+ elif role == Qt.FontRole:
57
+ font = QFont()
58
+ # Make variable names bold
59
+ if index.column() == 0:
60
+ font.setBold(True)
61
+ return font
62
+
63
+ elif role == Qt.BackgroundRole:
64
+ # Color coding based on variable type
65
+ type_name = var_data['type']
66
+ if 'int' in type_name or 'float' in type_name:
67
+ return QBrush(QColor(240, 248, 255)) # Light blue for numbers
68
+ elif 'str' in type_name:
69
+ return QBrush(QColor(255, 248, 240)) # Light orange for strings
70
+ elif 'list' in type_name or 'tuple' in type_name:
71
+ return QBrush(QColor(240, 255, 240)) # Light green for sequences
72
+
73
+ return None
74
+
75
+ def headerData(self, section, orientation, role=Qt.DisplayRole):
76
+ if orientation == Qt.Horizontal and role == Qt.DisplayRole:
77
+ return self._headers[section]
78
+ return None
79
+
80
+ class WorkspaceExplorer(Dock):
81
+ """Dock widget for exploring variables in the Jupyter workspace"""
82
+
83
+ def __init__(self, parent=None):
84
+ super().__init__("workspace_explorer", parent)
85
+ self.setObjectName('workspace_explorer')
86
+ # Main widget and layout
87
+ self.main_widget = QWidget(self)
88
+ self.setWidget(self.main_widget)
89
+ self.layout = QVBoxLayout(self.main_widget)
90
+
91
+ # Search bar
92
+ self.search_layout = QHBoxLayout()
93
+ self.search_input = QLineEdit()
94
+ self.search_input.setPlaceholderText("Filter …")
95
+ self.search_layout.addWidget(self.search_input)
96
+ self.layout.addLayout(self.search_layout)
97
+ self.layout.setContentsMargins(0, 0, 0, 0)
98
+
99
+ # Table view
100
+ self.table_view = QTableView()
101
+ self.table_view.setSelectionBehavior(QAbstractItemView.SelectRows)
102
+ self.table_view.setContextMenuPolicy(Qt.CustomContextMenu)
103
+ self.table_view.customContextMenuRequested.connect(self._show_context_menu)
104
+
105
+ # Model setup
106
+ self.model = WorkspaceModel(self)
107
+ self.proxy_model = QSortFilterProxyModel(self)
108
+ self.proxy_model.setSourceModel(self.model)
109
+ self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive)
110
+ self.proxy_model.setFilterKeyColumn(-1) # Search all columns
111
+
112
+ self.table_view.setModel(self.proxy_model)
113
+ self.layout.addWidget(self.table_view)
114
+
115
+ # Connect search field
116
+ self.search_input.textChanged.connect(self.proxy_model.setFilterFixedString)
117
+
118
+ # Set up the table appearance
119
+ header = self.table_view.horizontalHeader()
120
+ header.setSectionResizeMode(0, QHeaderView.Interactive) # Name
121
+ header.setSectionResizeMode(1, QHeaderView.Interactive) # Type
122
+ header.setSectionResizeMode(2, QHeaderView.Stretch) # Value
123
+
124
+ # Initial column widths
125
+ self.table_view.setColumnWidth(0, 150) # Name
126
+ self.table_view.setColumnWidth(1, 100) # Type
127
+
128
+ # Enable sorting
129
+ self.table_view.setSortingEnabled(True)
130
+
131
+ def update(self, workspace_data):
132
+ """Update the view with new workspace data"""
133
+ self.model.update_data(workspace_data)
134
+ # Resize rows to content
135
+ for row in range(self.model.rowCount()):
136
+ self.table_view.resizeRowToContents(row)
137
+
138
+ def _show_context_menu(self, position):
139
+ """Show context menu for actions on variables"""
140
+ menu = QMenu()
141
+
142
+ # Get the variable at cursor position
143
+ index = self.table_view.indexAt(position)
144
+ if index.isValid():
145
+ # Map through proxy model to get the actual variable name
146
+ source_index = self.proxy_model.mapToSource(index)
147
+ row = source_index.row()
148
+ var_name = self.model._var_list[row]
149
+
150
+ # Add actions specific to the variable
151
+ inspect_action = QAction(f"Inspect '{var_name}'", self)
152
+ delete_action = QAction(f"Delete '{var_name}'", self)
153
+
154
+ menu.addAction(inspect_action)
155
+ menu.addAction(delete_action)
156
+
157
+ # Show the menu
158
+ action = menu.exec_(self.table_view.mapToGlobal(position))
159
+
160
+ # Handle action selection
161
+ # (In a real implementation, would connect to actual functionality)
162
+
@@ -0,0 +1,15 @@
1
+ from .auto_pair import AutoPair, PythonAutoPair
2
+ from .base import Base
3
+ from .check import Check
4
+ from .comment import Comment
5
+ from .complete import Complete
6
+ from .execute import Execute
7
+ from .file_link import FileLink
8
+ from .line_number import LineNumber
9
+ from .python_auto_indent import PythonAutoIndent
10
+ from .search_replace import SearchReplace
11
+ from .smart_backspace_delete import SmartBackspaceDelete
12
+ from .symbols import Symbols
13
+ from .shortcuts import Shortcuts
14
+ from .theme import Theme
15
+ from .zoom import Zoom
@@ -0,0 +1,201 @@
1
+ from qtpy.QtCore import Qt
2
+ import logging
3
+ logging.basicConfig(level=logging.INFO, force=True)
4
+
5
+
6
+ class AutoPair:
7
+ """
8
+ Generic mixin for auto-pairing brackets/quotes/etc.
9
+
10
+ You can override self.PAIRS to add or remove different open/close
11
+ sequences. Each entry is a dict with:
12
+ - open_seq: string that triggers auto-pair (e.g. '(' or '\"\"\"')
13
+ - close_seq: string that is inserted (e.g. ')' or '\"\"\"')
14
+ - inbetween_seq: optional string inserted between open_seq and close_seq
15
+ (can be empty). e.g. '\n' for triple quotes.
16
+ """
17
+
18
+ PAIRS = [
19
+ {"open_seq": "(", "close_seq": ")", "inbetween_seq": ""},
20
+ {"open_seq": "[", "close_seq": "]", "inbetween_seq": ""},
21
+ {"open_seq": "{", "close_seq": "}", "inbetween_seq": ""},
22
+ {"open_seq": "\"", "close_seq": "\"", "inbetween_seq": ""},
23
+ {"open_seq": "\'", "close_seq": "\'", "inbetween_seq": ""},
24
+ ]
25
+
26
+ def __init__(self, *args, **kwargs):
27
+ super().__init__(*args, **kwargs)
28
+ self.setUndoRedoEnabled(True)
29
+ # For scanning up to the max length of an open_seq
30
+ self._max_open_len = max(len(pair["open_seq"]) for pair in self.PAIRS)
31
+
32
+ def keyPressEvent(self, event):
33
+ cursor = self.textCursor()
34
+ old_pos = cursor.position()
35
+ selected_text = cursor.selectedText()
36
+
37
+ # Allows auto-pairing to be disabled for certain positions
38
+ if self._disabled_for_position(old_pos):
39
+ super().keyPressEvent(event)
40
+ return
41
+
42
+ # 1) Handle backspace for removing empty pairs like (|)
43
+ if event.key() == Qt.Key_Backspace:
44
+ if self._handle_auto_pair_backspace():
45
+ return
46
+
47
+ typed_char = event.text()
48
+
49
+ # Pass the event up (inserts the typed_char)
50
+ super().keyPressEvent(event)
51
+
52
+ # 2) Possibly skip duplicate closing bracket/quote
53
+ # when the user manually types it.
54
+ if typed_char and len(typed_char) == 1:
55
+ for pair in self.PAIRS:
56
+ if typed_char == pair["close_seq"]:
57
+ new_cursor = self.textCursor()
58
+ doc_text = self.toPlainText()
59
+ if (new_cursor.position() < len(doc_text) and
60
+ doc_text[new_cursor.position()] == typed_char):
61
+ # We remove the newly typed character and move cursor forward
62
+ new_cursor.beginEditBlock()
63
+ # Remove the bracket that was just typed
64
+ new_cursor.setPosition(old_pos)
65
+ new_cursor.deleteChar()
66
+ # Move cursor to skip the existing bracket
67
+ new_cursor.setPosition(old_pos + 1)
68
+ new_cursor.endEditBlock()
69
+ self.setTextCursor(new_cursor)
70
+ break
71
+
72
+ # 3) After insertion, see if the text before the cursor matches an 'open_seq'
73
+ # If it does, insert close_seq + inbetween_seq, then restore cursor.
74
+ new_cursor = self.textCursor()
75
+ new_pos = new_cursor.position()
76
+ text = self.toPlainText()
77
+
78
+ # We'll scan backward from new_pos for up to _max_open_len chars
79
+ start_search = max(0, new_pos - self._max_open_len)
80
+ just_typed = text[start_search:new_pos]
81
+
82
+ for pair in self.PAIRS:
83
+ open_seq = pair["open_seq"]
84
+ close_seq = pair["close_seq"]
85
+ inbetween_seq = pair["inbetween_seq"]
86
+
87
+ if just_typed.endswith(open_seq) and event.text() == text[new_pos - 1: new_pos]:
88
+ self._insert_pair(open_seq, close_seq, inbetween_seq,
89
+ selected_text)
90
+ break
91
+
92
+
93
+ def _handle_auto_pair_backspace(self) -> bool:
94
+ """
95
+ If the cursor is between an exact open_seq and close_seq pair (e.g., '(|)'),
96
+ remove the full pair. Returns True if handled, False if not.
97
+ """
98
+ cursor = self.textCursor()
99
+ pos = cursor.position()
100
+ text = self.toPlainText()
101
+
102
+ for pair in self.PAIRS:
103
+ open_seq = pair["open_seq"]
104
+ close_seq = pair["close_seq"]
105
+ if open_seq and close_seq:
106
+ l_open = len(open_seq)
107
+ l_close = len(close_seq)
108
+
109
+ # Make sure there's enough room before and after the cursor
110
+ if pos >= l_open and pos + l_close <= len(text):
111
+ behind = text[pos - l_open : pos]
112
+ ahead = text[pos : pos + l_close]
113
+
114
+ # If the cursor is right between open_seq and close_seq
115
+ # with nothing typed in between (e.g. (|))
116
+ if behind == open_seq and ahead == close_seq:
117
+ c = self.textCursor()
118
+ c.beginEditBlock()
119
+ # Remove from open_seq start to close_seq end
120
+ c.setPosition(pos - l_open)
121
+ for _ in range(l_open + l_close):
122
+ c.deleteChar()
123
+ c.endEditBlock()
124
+
125
+ # Move cursor to the old open_seq start
126
+ c.setPosition(pos - l_open)
127
+ self.setTextCursor(c)
128
+ return True
129
+ return False
130
+
131
+
132
+ def _insert_pair(self, open_seq, close_seq, inbetween_seq, selected_text):
133
+ """
134
+ Called when we recognize that the user typed an open_seq in full.
135
+ Insert the close_seq plus any inbetween_seq, and restore the cursor
136
+ to the original position (right after the open_seq).
137
+ """
138
+ c = self.textCursor()
139
+
140
+ # We'll remember where the user ended up (right after open_seq).
141
+ old_pos = c.position()
142
+ if inbetween_seq == '\n':
143
+ # Get indentation level of block after cursor
144
+ block = self.document().findBlock(old_pos)
145
+ block_text = block.text()
146
+ indent = block_text[:len(block_text) - len(block_text.lstrip())]
147
+ inbetween_seq = '\n' + indent
148
+ # Insert the close_seq and inbetween_seq, indented to the same level as the block
149
+ c.beginEditBlock()
150
+
151
+ # Insert in-between text, then the closing
152
+ c.insertText(selected_text + inbetween_seq + close_seq)
153
+
154
+ # Move the cursor back so that it's right after the open_seq and (if any)
155
+ # the selected text
156
+ c.movePosition(c.Left, c.MoveAnchor, len(close_seq) + len(inbetween_seq))
157
+
158
+ c.endEditBlock()
159
+ self.setTextCursor(c)
160
+
161
+ logging.info(
162
+ "Auto-paired '%s' with '%s', inserted in-between '%s'. "
163
+ "Cursor restored to position %d",
164
+ open_seq, close_seq, inbetween_seq, old_pos
165
+ )
166
+
167
+ def _disabled_for_position(self, pos):
168
+ return False
169
+
170
+
171
+ class PythonAutoPair(AutoPair):
172
+ PAIRS = [
173
+ # Example triple quotes for Python:
174
+ {"open_seq": "\"\"\"", "close_seq": "\"\"\"", "inbetween_seq": "\n"},
175
+ {"open_seq": "'''", "close_seq": "'''", "inbetween_seq": "\n"},
176
+ {"open_seq": "(", "close_seq": ")", "inbetween_seq": ""},
177
+ {"open_seq": "[", "close_seq": "]", "inbetween_seq": ""},
178
+ {"open_seq": "{", "close_seq": "}", "inbetween_seq": ""},
179
+ {"open_seq": "\"", "close_seq": "\"", "inbetween_seq": ""},
180
+ {"open_seq": "\'", "close_seq": "\'", "inbetween_seq": ""},
181
+ ]
182
+
183
+ def _disabled_for_position(self, pos):
184
+ """
185
+ Returns True if 'pos' (a QTextCursor.position() in self)
186
+ is (likely) inside a comment in Python code by checking
187
+ if there's a '#' before the cursor on this line.
188
+ Otherwise returns False.
189
+ """
190
+ block = self.document().findBlock(pos)
191
+ line_text = block.text()
192
+ column = pos - block.position()
193
+
194
+ # Find the first '#' in line_text
195
+ hash_index = line_text.find('#')
196
+
197
+ # If a '#' is found and it's to the left of the cursor, assume comment
198
+ if hash_index != -1 and hash_index < column:
199
+ return True
200
+
201
+ return False
@@ -0,0 +1,142 @@
1
+ from ..worker import manager
2
+ from qtpy.QtCore import QTimer, Signal
3
+ from qtpy.QtGui import QTextCursor
4
+ import logging
5
+ logging.basicConfig(level=logging.INFO, force=True)
6
+ logger = logging.getLogger(__name__)
7
+
8
+ active_editor = None
9
+
10
+
11
+ class Base:
12
+
13
+ code_editor_file_path = None
14
+ code_editor_colors = None
15
+ code_editor_language = 'text'
16
+ modification_changed = Signal(object, bool)
17
+ received_focus = Signal(object)
18
+
19
+ def __init__(self, *args, **kwargs):
20
+ # Optionally set the language. This is mainly for generic editors that
21
+ # need to support multiple languages. This needs to be stripped from
22
+ # the kwargs because it's not supported by QPlainTextEdit
23
+ language = kwargs.pop('language', None)
24
+ if language is not None:
25
+ self.code_editor_language = language
26
+ super().__init__(*args, **kwargs)
27
+ logger.info("Initializing Base")
28
+ self.installEventFilter(self)
29
+ # Poll timer to retrieve results from the worker
30
+ self._cm_result_queue = None
31
+ self._cm_worker_pid = None
32
+ self._cm_poll_timer = QTimer(self)
33
+ self._cm_poll_timer.setInterval(50) # 10 times/second
34
+ self._cm_poll_timer.timeout.connect(self._cm_check_result)
35
+ self._cm_poll_timer.start()
36
+ self.code_editor_line_annotations = {}
37
+ # Keep track of modified status
38
+ self.modified = False
39
+ self.modificationChanged.connect(self.set_modified)
40
+ # Dictionary mapping pid -> result queue
41
+ self._active_requests = {}
42
+
43
+ def eventFilter(self, obj, event):
44
+ """Can be implement in other mixin classes to filter certain events,
45
+ for example to avoid certain keypresses from being consumed.
46
+ """
47
+ return False
48
+
49
+ def focusInEvent(self, event):
50
+ """Allows managing widgets, such as the editor panel, to keep track of
51
+ which editor is active
52
+ """
53
+ super().focusInEvent(event)
54
+ self.received_focus.emit(self)
55
+
56
+ def refresh(self):
57
+ """Can be called to indicate that the interface needs to be refreshed,
58
+ and implement in other mixin classes to handle the actual refresh
59
+ logic.
60
+ """
61
+ pass
62
+
63
+ def send_worker_request(self, **data):
64
+ """
65
+ Send a request to the worker manager.
66
+ This returns a unique queue/pid pair each time,
67
+ so we can handle multiple concurrent requests.
68
+ """
69
+ result_queue, pid = manager.send_worker_request(**data)
70
+ self._active_requests[pid] = result_queue
71
+ logger.info(f"Sent request to worker {pid}, now tracking {len(self._active_requests)} active requests.")
72
+
73
+ def handle_worker_result(self, action, result):
74
+ """
75
+ Subclasses can override this to do specialized handling
76
+ for the action & result from the worker process.
77
+ """
78
+ pass
79
+
80
+ def _cm_check_result(self):
81
+ """
82
+ Called periodically (e.g. via a QTimer) to check each active
83
+ worker's queue. If there's a result, handle it;
84
+ if the worker died or the result queue is empty, keep polling.
85
+ """
86
+ to_remove = []
87
+ for pid, queue in self._active_requests.items():
88
+ # 1) Check if the worker is still alive
89
+ if not manager.check_worker_alive(pid):
90
+ logger.warning(f"Worker process {pid} no longer alive. Removing from active requests.")
91
+ to_remove.append(pid)
92
+ continue
93
+
94
+ # 2) If queue is empty, nothing more to do right now
95
+ if queue.empty():
96
+ continue
97
+
98
+ # 3) Retrieve the result, mark worker free
99
+ result = queue.get()
100
+ manager.mark_worker_as_free(pid)
101
+
102
+ # 4) Validate result structure
103
+ if not isinstance(result, dict):
104
+ logger.info(f"Got invalid response (not a dict) from {pid}: {result}")
105
+ to_remove.append(pid)
106
+ continue
107
+
108
+ # 5) Extract action and pass it to our handler
109
+ action = result.pop('action', None)
110
+ if action is None:
111
+ logger.info(f"Missing 'action' in worker response: {result}")
112
+ to_remove.append(pid)
113
+ continue
114
+
115
+ logger.info(f"Received worker result from {pid}: action={action}")
116
+ self.handle_worker_result(action, result)
117
+
118
+ # 6) We're done with this particular request
119
+ to_remove.append(pid)
120
+
121
+ # Cleanup: remove completed or dead requests
122
+ for pid in to_remove:
123
+ self._active_requests.pop(pid, None)
124
+
125
+ def set_modified(self, modified):
126
+ logger.info(f'modified: {modified}')
127
+ self.document().setModified(modified)
128
+ self.modified = modified
129
+ self.modification_changed.emit(self, modified)
130
+
131
+ def jump_to_line(self, line_number=0):
132
+ cursor = self.textCursor()
133
+ cursor.movePosition(QTextCursor.Start)
134
+ cursor.movePosition(QTextCursor.Down, QTextCursor.MoveAnchor, line_number)
135
+ self.setTextCursor(cursor)
136
+ self.ensureCursorVisible()
137
+
138
+ def update_theme(self):
139
+ """Mixins can implement this to update the theme in response to font changes
140
+ etc.
141
+ """
142
+ pass
@@ -0,0 +1,94 @@
1
+ import logging; logging.basicConfig(level=logging.INFO, force=True)
2
+ from qtpy.QtWidgets import QWidget, QPlainTextEdit
3
+ from qtpy.QtCore import Qt, QRect, QPoint, QSize, QTimer
4
+ from qtpy.QtGui import QPainter, QColor, QBrush
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class Check:
9
+ """
10
+ This is a mixin class that adds code checking / linting powers to a QPlainTextEdit.
11
+
12
+ Now with a debounce mechanism so we don't request checks on every keystroke.
13
+ """
14
+ def __init__(self, *args, **kwargs):
15
+ super().__init__(*args, **kwargs)
16
+ logger.info("Initializing Check")
17
+
18
+ # Where lint issues will be stored once the worker returns them
19
+ self._issues = []
20
+
21
+ # Debounce timer: when the user stops typing for N ms, we trigger the lint action
22
+ self._check_debounce_timer = QTimer(self)
23
+ self._check_debounce_timer.setSingleShot(True)
24
+ self._check_debounce_timer.setInterval(500)
25
+ self._check_debounce_timer.timeout.connect(self._execute_check)
26
+ # Periodic timer: also periodically run a check
27
+ self._check_interval_timer = QTimer(self)
28
+ self._check_interval_timer.setInterval(5000)
29
+ self._check_interval_timer.timeout.connect(self._execute_check)
30
+
31
+ # Connect textChanged signal to a debounced method
32
+ self.textChanged.connect(self._on_code_changed)
33
+
34
+ def _on_code_changed(self):
35
+ """
36
+ Called immediately on text changes. Rather than calling
37
+ the worker here, we restart the debounce timer so that
38
+ checks won't happen until the user is idle for ~500ms.
39
+ """
40
+ if self._check_debounce_timer.isActive():
41
+ self._check_debounce_timer.stop()
42
+ self._check_debounce_timer.start()
43
+
44
+ def _execute_check(self):
45
+ """
46
+ Runs the actual lint check after the debounce period.
47
+ """
48
+ code = self.toPlainText()
49
+ logger.info("Requesting lint check")
50
+ self.send_worker_request(
51
+ action='check',
52
+ code=code,
53
+ language=getattr(self, "code_editor_language", "python")
54
+ )
55
+
56
+ def handle_worker_result(self, action: str, result):
57
+ """
58
+ This is where we receive code-check results from the worker.
59
+ 'action' will be 'check' for lint checks. 'result' is a dict
60
+ with a 'messages' key that holds the issues.
61
+ """
62
+ super().handle_worker_result(action, result)
63
+ if action != 'check':
64
+ return
65
+ # Clear all previous check annotations
66
+ self.code_editor_line_annotations = {
67
+ line_number: annotation for line_number, annotation
68
+ in self.code_editor_line_annotations.items()
69
+ if annotation['type'] != 'check'
70
+ }
71
+ background_color = QColor(self.code_editor_colors["highlight"])
72
+ color = QColor(self.code_editor_colors["text"])
73
+ for line_number, annotations in result['messages'].items():
74
+ if not annotations:
75
+ continue
76
+ # There may be multiple annotations for one line. If so, we use the
77
+ # code of the first annotation to determine the symbol.
78
+ if annotations[0].get('code', False):
79
+ symbol = '➜'
80
+ else:
81
+ symbol = '❌'
82
+ description = '\n'.join([
83
+ str(annotation['code']) + ': ' + annotation['message']
84
+ for annotation in annotations
85
+ ])
86
+ annotation = {
87
+ 'type': 'check',
88
+ 'text': f'{symbol}{line_number}',
89
+ 'background_color': background_color,
90
+ 'color': color,
91
+ 'tooltip': description
92
+ }
93
+ self.code_editor_line_annotations[line_number] = annotation
94
+ self.repaint()