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.
- pyqt_code_editor/__init__.py +5 -0
- pyqt_code_editor/_settings.py +232 -0
- pyqt_code_editor/app.py +438 -0
- pyqt_code_editor/code_editors/__init__.py +1 -0
- pyqt_code_editor/code_editors/code_editors.py +33 -0
- pyqt_code_editor/code_editors/languages/__init__.py +0 -0
- pyqt_code_editor/code_editors/languages/generic.py +11 -0
- pyqt_code_editor/code_editors/languages/markdown.py +11 -0
- pyqt_code_editor/code_editors/languages/python.py +13 -0
- pyqt_code_editor/code_editors/languages/text.py +9 -0
- pyqt_code_editor/components/__init__.py +0 -0
- pyqt_code_editor/components/editor_panel.py +399 -0
- pyqt_code_editor/components/find_in_files.py +199 -0
- pyqt_code_editor/components/jupyter_console.py +487 -0
- pyqt_code_editor/components/project_explorer.py +515 -0
- pyqt_code_editor/components/settings_panel.py +162 -0
- pyqt_code_editor/components/sigmund.py +75 -0
- pyqt_code_editor/components/workspace_explorer.py +162 -0
- pyqt_code_editor/mixins/__init__.py +15 -0
- pyqt_code_editor/mixins/auto_pair.py +201 -0
- pyqt_code_editor/mixins/base.py +142 -0
- pyqt_code_editor/mixins/check.py +94 -0
- pyqt_code_editor/mixins/comment.py +91 -0
- pyqt_code_editor/mixins/complete.py +482 -0
- pyqt_code_editor/mixins/execute.py +182 -0
- pyqt_code_editor/mixins/file_link.py +166 -0
- pyqt_code_editor/mixins/line_number.py +206 -0
- pyqt_code_editor/mixins/python_auto_indent.py +239 -0
- pyqt_code_editor/mixins/search_replace.py +396 -0
- pyqt_code_editor/mixins/shortcuts.py +228 -0
- pyqt_code_editor/mixins/smart_backspace_delete.py +120 -0
- pyqt_code_editor/mixins/symbols.py +45 -0
- pyqt_code_editor/mixins/theme.py +99 -0
- pyqt_code_editor/mixins/widgets/__init__.py +0 -0
- pyqt_code_editor/mixins/zoom.py +51 -0
- pyqt_code_editor/python_utils.py +936 -0
- pyqt_code_editor/signal_router.py +116 -0
- pyqt_code_editor/syntax_highlighters/__init__.py +0 -0
- pyqt_code_editor/syntax_highlighters/languages/__init__.py +0 -0
- pyqt_code_editor/syntax_highlighters/languages/generic.py +86 -0
- pyqt_code_editor/syntax_highlighters/languages/python.py +62 -0
- pyqt_code_editor/syntax_highlighters/syntax_highlighter.py +26 -0
- pyqt_code_editor/themes.py +36 -0
- pyqt_code_editor/utils.py +96 -0
- pyqt_code_editor/watchdog.py +141 -0
- pyqt_code_editor/widgets/__init__.py +6 -0
- pyqt_code_editor/widgets/calltip_widget.py +44 -0
- pyqt_code_editor/widgets/completion_popup.py +89 -0
- pyqt_code_editor/widgets/dock.py +15 -0
- pyqt_code_editor/widgets/quick_open_dialog.py +196 -0
- pyqt_code_editor/widgets/quick_open_file_dialog.py +32 -0
- pyqt_code_editor/widgets/quick_symbol_dialog.py +13 -0
- pyqt_code_editor/widgets/search_replace_frame.py +128 -0
- pyqt_code_editor/widgets/tab_splitter.py +22 -0
- pyqt_code_editor/widgets/tabbed_editor.py +351 -0
- pyqt_code_editor/worker/__init__.py +0 -0
- pyqt_code_editor/worker/languages/__init__.py +0 -0
- pyqt_code_editor/worker/languages/generic.py +15 -0
- pyqt_code_editor/worker/languages/python.py +17 -0
- pyqt_code_editor/worker/manager.py +113 -0
- pyqt_code_editor/worker/process.py +128 -0
- pyqt_code_editor/worker/providers/__init__.py +1 -0
- pyqt_code_editor/worker/providers/codestral.py +56 -0
- pyqt_code_editor/worker/providers/jedi.py +115 -0
- pyqt_code_editor/worker/providers/ruff.py +45 -0
- pyqt_code_editor/worker/providers/sigmund.py +29 -0
- pyqt_code_editor/worker/providers/symbol.py +78 -0
- pyqt_code_editor/worker/settings.py +1 -0
- pyqt_code_editor-0.0.1.dist-info/METADATA +69 -0
- pyqt_code_editor-0.0.1.dist-info/RECORD +73 -0
- pyqt_code_editor-0.0.1.dist-info/WHEEL +4 -0
- pyqt_code_editor-0.0.1.dist-info/entry_points.txt +4 -0
- 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()
|