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,91 @@
|
|
|
1
|
+
from qtpy.QtWidgets import QShortcut
|
|
2
|
+
from qtpy.QtGui import QKeySequence
|
|
3
|
+
from qtpy.QtCore import Qt
|
|
4
|
+
from .. import settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Comment:
|
|
8
|
+
"""
|
|
9
|
+
A mixin class to add (un)commenting functionality to a QPlainTextEdit.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
code_editor_comment_string = '# '
|
|
13
|
+
|
|
14
|
+
def __init__(self, *args, **kwargs):
|
|
15
|
+
"""
|
|
16
|
+
:param comment_string: The comment string to prepend for each line
|
|
17
|
+
(default "# " for Python).
|
|
18
|
+
"""
|
|
19
|
+
super().__init__(*args, **kwargs)
|
|
20
|
+
# The text to prepend for a commented line
|
|
21
|
+
self.code_editor_comment_string
|
|
22
|
+
self._comment_shortcut = QShortcut(
|
|
23
|
+
QKeySequence(settings.shortcut_comment), self)
|
|
24
|
+
self._comment_shortcut.setContext(Qt.WidgetWithChildrenShortcut)
|
|
25
|
+
self._comment_shortcut.activated.connect(self._toggle_comment)
|
|
26
|
+
|
|
27
|
+
def _toggle_comment(self):
|
|
28
|
+
"""Toggle comment on the current selection or current line."""
|
|
29
|
+
cursor = self.textCursor()
|
|
30
|
+
if not cursor.hasSelection():
|
|
31
|
+
# If no selection, just operate on the current line
|
|
32
|
+
cursor.select(cursor.LineUnderCursor)
|
|
33
|
+
|
|
34
|
+
start = cursor.selectionStart()
|
|
35
|
+
end = cursor.selectionEnd()
|
|
36
|
+
|
|
37
|
+
# Re-select with partial offsets set to the entire lines
|
|
38
|
+
cursor.setPosition(start)
|
|
39
|
+
startBlock = cursor.blockNumber()
|
|
40
|
+
cursor.setPosition(end, cursor.KeepAnchor)
|
|
41
|
+
endBlock = cursor.blockNumber()
|
|
42
|
+
|
|
43
|
+
# Decide whether to comment or uncomment
|
|
44
|
+
all_commented = True
|
|
45
|
+
for block_num in range(startBlock, endBlock + 1):
|
|
46
|
+
block = self.document().findBlockByNumber(block_num)
|
|
47
|
+
text = block.text()
|
|
48
|
+
if text.strip() and not text.lstrip().startswith(self.code_editor_comment_string):
|
|
49
|
+
all_commented = False
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
if all_commented:
|
|
53
|
+
self._uncomment_blocks(startBlock, endBlock)
|
|
54
|
+
else:
|
|
55
|
+
self._comment_blocks(startBlock, endBlock)
|
|
56
|
+
|
|
57
|
+
def _comment_blocks(self, start_block: int, end_block: int):
|
|
58
|
+
"""Comment all lines from start_block to end_block."""
|
|
59
|
+
cursor = self.textCursor()
|
|
60
|
+
cursor.beginEditBlock() # group undo steps
|
|
61
|
+
for block_num in range(start_block, end_block + 1):
|
|
62
|
+
block = self.document().findBlockByNumber(block_num)
|
|
63
|
+
if not block.isValid():
|
|
64
|
+
continue
|
|
65
|
+
text = block.text()
|
|
66
|
+
# Insert comment string at first non-whitespace character
|
|
67
|
+
leading_spaces = len(text) - len(text.lstrip())
|
|
68
|
+
insert_position = block.position() + leading_spaces
|
|
69
|
+
cursor.setPosition(insert_position)
|
|
70
|
+
cursor.insertText(self.code_editor_comment_string)
|
|
71
|
+
cursor.endEditBlock()
|
|
72
|
+
|
|
73
|
+
def _uncomment_blocks(self, start_block: int, end_block: int):
|
|
74
|
+
"""Uncomment all lines from start_block to end_block."""
|
|
75
|
+
cursor = self.textCursor()
|
|
76
|
+
cursor.beginEditBlock()
|
|
77
|
+
for block_num in range(start_block, end_block + 1):
|
|
78
|
+
block = self.document().findBlockByNumber(block_num)
|
|
79
|
+
if not block.isValid():
|
|
80
|
+
continue
|
|
81
|
+
text = block.text()
|
|
82
|
+
strip_len = len(self.code_editor_comment_string)
|
|
83
|
+
# If the line is commented (discount leading whitespace), remove the comment string
|
|
84
|
+
if text.lstrip().startswith(self.code_editor_comment_string):
|
|
85
|
+
leading_spaces = len(text) - len(text.lstrip())
|
|
86
|
+
remove_position = block.position() + leading_spaces
|
|
87
|
+
cursor.setPosition(remove_position)
|
|
88
|
+
cursor.setPosition(remove_position + strip_len, cursor.KeepAnchor)
|
|
89
|
+
if cursor.selectedText() == self.code_editor_comment_string.rstrip('\r\n'):
|
|
90
|
+
cursor.removeSelectedText()
|
|
91
|
+
cursor.endEditBlock()
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from qtpy.QtCore import QTimer, Qt
|
|
3
|
+
from qtpy.QtGui import QTextCursor
|
|
4
|
+
from ..widgets.completion_popup import CompletionPopup
|
|
5
|
+
from ..widgets.calltip_widget import CalltipWidget
|
|
6
|
+
from .. import settings
|
|
7
|
+
logging.basicConfig(level=logging.INFO, force=True)
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Complete:
|
|
12
|
+
"""
|
|
13
|
+
A mixin providing code-completion logic, designed to be paired with
|
|
14
|
+
a QPlainTextEdit (or derived) class in multiple inheritance.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *args, **kwargs):
|
|
18
|
+
super().__init__(*args, **kwargs)
|
|
19
|
+
logger.info("Initializing Complete")
|
|
20
|
+
|
|
21
|
+
self._cm_full_completion_timer = QTimer(self)
|
|
22
|
+
self._cm_full_completion_timer.setSingleShot(True)
|
|
23
|
+
self._cm_full_completion_timer.setInterval(settings.full_completion_delay)
|
|
24
|
+
self._cm_full_completion_timer.timeout.connect(self._cm_full_completion_dispatch)
|
|
25
|
+
|
|
26
|
+
self._cm_hide_completion_timer = QTimer(self)
|
|
27
|
+
self._cm_hide_completion_timer.setSingleShot(True)
|
|
28
|
+
self._cm_hide_completion_timer.setInterval(settings.hide_completion_delay)
|
|
29
|
+
self._cm_hide_completion_timer.timeout.connect(self._cm_hide_completion_dispatch)
|
|
30
|
+
|
|
31
|
+
self._ignore_next_completion = False
|
|
32
|
+
|
|
33
|
+
# Track the cursor position when a request was made
|
|
34
|
+
self._cm_requested_cursor_pos = None
|
|
35
|
+
self._cm_requested_calltip_cursor_pos = None
|
|
36
|
+
|
|
37
|
+
# Create the popup for completions
|
|
38
|
+
self._cm_completion_popup = CompletionPopup(self)
|
|
39
|
+
|
|
40
|
+
# Create (but keep hidden) our persistent calltip widget
|
|
41
|
+
self._cm_calltip_widget = CalltipWidget(self)
|
|
42
|
+
self._cm_calltip_widget.hide()
|
|
43
|
+
self._cm_paren_prefix = None
|
|
44
|
+
logger.info("Complete initialized.")
|
|
45
|
+
|
|
46
|
+
def _update_paren_prefix_cache(self):
|
|
47
|
+
"""
|
|
48
|
+
Build a prefix array self._cm_paren_prefix so that
|
|
49
|
+
self._cm_paren_prefix[i] = net # of '(' minus ')' from the start of the text up to (but not including) index i.
|
|
50
|
+
|
|
51
|
+
Then, for a cursor position p, if self._cm_paren_prefix[p] > 0, we know there's at least one unmatched '('.
|
|
52
|
+
"""
|
|
53
|
+
text = self.toPlainText()
|
|
54
|
+
prefix = [0] * (len(text) + 1) # prefix[0] = 0, prefix[i] for i>0 is the balance up to i-1
|
|
55
|
+
balance = 0
|
|
56
|
+
for i, ch in enumerate(text):
|
|
57
|
+
if ch == '(':
|
|
58
|
+
balance += 1
|
|
59
|
+
elif ch == ')':
|
|
60
|
+
balance -= 1
|
|
61
|
+
prefix[i + 1] = balance
|
|
62
|
+
self._cm_paren_prefix = prefix
|
|
63
|
+
|
|
64
|
+
def _cursor_follows_unclosed_paren(self):
|
|
65
|
+
"""
|
|
66
|
+
Use the prefix cache to quickly check if the current cursor
|
|
67
|
+
is inside an unmatched '(' context.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
pos = self.textCursor().position()
|
|
71
|
+
if self._cm_paren_prefix is None or pos >= len(self._cm_paren_prefix):
|
|
72
|
+
self._update_paren_prefix_cache()
|
|
73
|
+
return self._cm_paren_prefix[pos] > 0
|
|
74
|
+
|
|
75
|
+
def _is_navigation_key(self, event):
|
|
76
|
+
"""Return True if event is a navigation key (arrow/home/end/page)."""
|
|
77
|
+
nav_keys = {
|
|
78
|
+
Qt.Key_Up, Qt.Key_Down, Qt.Key_Home, Qt.Key_End, Qt.Key_PageUp,
|
|
79
|
+
Qt.Key_PageDown
|
|
80
|
+
}
|
|
81
|
+
return event.key() in nav_keys
|
|
82
|
+
|
|
83
|
+
def mousePressEvent(self, event):
|
|
84
|
+
"""Make sure the calltip doesn't stay open when we navigate away with
|
|
85
|
+
the mouse.
|
|
86
|
+
"""
|
|
87
|
+
super().mousePressEvent(event)
|
|
88
|
+
self._cm_hide_and_recheck_calltip_if_unclosed()
|
|
89
|
+
self._cm_completion_popup.hide()
|
|
90
|
+
|
|
91
|
+
def wheelEvent(self, event):
|
|
92
|
+
"""Make sure the calltip doesn't stay open when we scroll the viewport.
|
|
93
|
+
"""
|
|
94
|
+
super().wheelEvent(event)
|
|
95
|
+
self._cm_hide_calltip()
|
|
96
|
+
self._cm_completion_popup.hide()
|
|
97
|
+
|
|
98
|
+
def focusOutEvent(self, event):
|
|
99
|
+
"""Make sure the calltip doesn't stay open when we scroll the viewport.
|
|
100
|
+
"""
|
|
101
|
+
super().focusOutEvent(event)
|
|
102
|
+
self._cm_hide_calltip()
|
|
103
|
+
self._cm_completion_popup.hide()
|
|
104
|
+
|
|
105
|
+
def keyPressEvent(self, event):
|
|
106
|
+
"""
|
|
107
|
+
Updated logic to prevent completion on arrow keys:
|
|
108
|
+
• We skip calling self._cm_full_completion_timer.start() if the user pressed a nav key.
|
|
109
|
+
"""
|
|
110
|
+
# When a key is pressed, we want to stop the full complete request. In case the
|
|
111
|
+
# request is already sent, we also set a flag to ignore the result when it comes
|
|
112
|
+
# in
|
|
113
|
+
if self._cm_full_completion_timer.isActive():
|
|
114
|
+
self._cm_full_completion_timer.stop()
|
|
115
|
+
self._ignore_next_completion = True
|
|
116
|
+
# Now process the key press
|
|
117
|
+
typed_char = event.text()
|
|
118
|
+
old_pos = self.textCursor().position() # remember cursor pos before super()
|
|
119
|
+
# 1) If the popup is visible, handle completion navigation or acceptance.
|
|
120
|
+
if self._cm_completion_popup.isVisible():
|
|
121
|
+
if event.key() in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Tab):
|
|
122
|
+
item = self._cm_completion_popup.currentItem()
|
|
123
|
+
if item is not None:
|
|
124
|
+
self._cm_completion_popup.insert_completion(item)
|
|
125
|
+
self._cm_completion_popup.hide()
|
|
126
|
+
event.accept()
|
|
127
|
+
return
|
|
128
|
+
elif event.key() == Qt.Key_Up:
|
|
129
|
+
row = self._cm_completion_popup.currentRow()
|
|
130
|
+
self._cm_completion_popup.setCurrentRow(max(0, row - 1))
|
|
131
|
+
event.accept()
|
|
132
|
+
return
|
|
133
|
+
elif event.key() == Qt.Key_Down:
|
|
134
|
+
row = self._cm_completion_popup.currentRow()
|
|
135
|
+
self._cm_completion_popup.setCurrentRow(min(self._cm_completion_popup.count() - 1, row + 1))
|
|
136
|
+
event.accept()
|
|
137
|
+
return
|
|
138
|
+
elif event.key() == Qt.Key_Escape:
|
|
139
|
+
self._cm_completion_popup.hide()
|
|
140
|
+
event.accept()
|
|
141
|
+
return
|
|
142
|
+
# Otherwise, fall through to normal handling below
|
|
143
|
+
|
|
144
|
+
# We hide the completion timer with a short delay. This is to make sure
|
|
145
|
+
# that the completion popup doesn't linger after key presses.
|
|
146
|
+
if not self._cm_hide_completion_timer.isActive():
|
|
147
|
+
self._cm_hide_completion_timer.start()
|
|
148
|
+
|
|
149
|
+
# 2) Detect Ctrl+Space => multiline completion
|
|
150
|
+
if (event.key() == Qt.Key_Space) and (event.modifiers() & Qt.ControlModifier):
|
|
151
|
+
logger.info("Detected Ctrl+Space => multiline completion.")
|
|
152
|
+
self._cm_request_completion(multiline=True)
|
|
153
|
+
event.accept()
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# 3) Check left/right arrow:
|
|
157
|
+
is_left = (event.key() == Qt.Key_Left)
|
|
158
|
+
is_right = (event.key() == Qt.Key_Right)
|
|
159
|
+
if is_left or is_right:
|
|
160
|
+
super().keyPressEvent(event)
|
|
161
|
+
new_pos = self.textCursor().position()
|
|
162
|
+
|
|
163
|
+
# If we moved left and jumped over '(' => hide & re-check
|
|
164
|
+
if is_left and (old_pos - new_pos == 1):
|
|
165
|
+
text = self.toPlainText()
|
|
166
|
+
if 0 <= new_pos < len(text) and text[new_pos] in '()':
|
|
167
|
+
self._cm_hide_and_recheck_calltip_if_unclosed()
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# If we moved right and jumped over ')' => hide & re-check
|
|
171
|
+
if is_right and (new_pos - old_pos == 1):
|
|
172
|
+
text = self.toPlainText()
|
|
173
|
+
if 0 <= old_pos < len(text) and text[old_pos] in '()':
|
|
174
|
+
self._cm_hide_and_recheck_calltip_if_unclosed()
|
|
175
|
+
return
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
# 4) If user pressed a navigation key (Home, End, PgUp, etc.), hide calltip and re-check
|
|
179
|
+
if self._is_navigation_key(event):
|
|
180
|
+
super().keyPressEvent(event)
|
|
181
|
+
self._cm_hide_and_recheck_calltip_if_unclosed()
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
# 5) If user typed ")", hide calltip immediately
|
|
185
|
+
if typed_char == ')':
|
|
186
|
+
super().keyPressEvent(event)
|
|
187
|
+
self._update_paren_prefix_cache()
|
|
188
|
+
logger.info("User typed ')' => hiding calltip.")
|
|
189
|
+
self._cm_hide_calltip()
|
|
190
|
+
# Possibly also finalize arguments => request normal completion
|
|
191
|
+
self._cm_full_completion_timer.start()
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
# 6) Detect if backspace removes '(' => hide calltip
|
|
195
|
+
backspace_removing_open_paren = False
|
|
196
|
+
if event.key() == Qt.Key_Backspace:
|
|
197
|
+
cursor = self.textCursor()
|
|
198
|
+
if cursor.position() > 0:
|
|
199
|
+
cursor.movePosition(cursor.Left, cursor.KeepAnchor)
|
|
200
|
+
if cursor.selectedText() == '(':
|
|
201
|
+
backspace_removing_open_paren = True
|
|
202
|
+
|
|
203
|
+
# Let the editor insert or remove the character normally
|
|
204
|
+
super().keyPressEvent(event)
|
|
205
|
+
|
|
206
|
+
# 7) Update the paren cache after text changed
|
|
207
|
+
self._update_paren_prefix_cache()
|
|
208
|
+
|
|
209
|
+
# 8) If user typed "(", request a calltip
|
|
210
|
+
if typed_char == '(':
|
|
211
|
+
logger.info("User typed '(' => requesting calltip.")
|
|
212
|
+
self._cm_request_calltip()
|
|
213
|
+
|
|
214
|
+
# 9) If we removed an "(", hide calltip
|
|
215
|
+
if backspace_removing_open_paren:
|
|
216
|
+
logger.info("User removed '(' => hiding calltip.")
|
|
217
|
+
self._cm_hide_calltip()
|
|
218
|
+
|
|
219
|
+
# 10) Hide/keep the popup based on typed_char
|
|
220
|
+
if typed_char:
|
|
221
|
+
if typed_char.isalnum() or typed_char in ('_', '.') or event.key() == Qt.Key_Backspace:
|
|
222
|
+
logger.info(f"User typed identifier-like char {typed_char!r}; keeping popup open (if visible).")
|
|
223
|
+
# Only start the debounce timer if we have an actual typed character,
|
|
224
|
+
# so pressing arrow/navigation keys never triggers completion.
|
|
225
|
+
self._cm_request_completion()
|
|
226
|
+
else:
|
|
227
|
+
logger.info(f"User typed non-identifier char {typed_char!r}; hiding popup.")
|
|
228
|
+
self._cm_completion_popup.hide()
|
|
229
|
+
|
|
230
|
+
else:
|
|
231
|
+
logger.info("No typed_char => not starting debounce timer.")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _cm_hide_and_recheck_calltip_if_unclosed(self):
|
|
235
|
+
"""
|
|
236
|
+
Helper to hide the calltip if currently visible, then re-request it if
|
|
237
|
+
we still have an unmatched '(' at the cursor.
|
|
238
|
+
"""
|
|
239
|
+
if self._cm_calltip_widget.isVisible():
|
|
240
|
+
self._cm_hide_calltip()
|
|
241
|
+
if self._cursor_follows_unclosed_paren():
|
|
242
|
+
self._cm_request_calltip()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _cm_full_completion_dispatch(self):
|
|
246
|
+
"""
|
|
247
|
+
Called by the debounce timer to request a full complete, which can include
|
|
248
|
+
more time-consuming completions, such as AI-generated completions.
|
|
249
|
+
"""
|
|
250
|
+
self._cm_request_completion(multiline=False, full=True)
|
|
251
|
+
|
|
252
|
+
def _cm_hide_completion_dispatch(self):
|
|
253
|
+
"""
|
|
254
|
+
Called by the debounce timer to hide a completion with a short delay after
|
|
255
|
+
a key press. This is to avoid completions from remaining visible after the
|
|
256
|
+
cursor has moved, in case no completion came in to hide or replace it.
|
|
257
|
+
"""
|
|
258
|
+
if self._cm_completion_popup.isVisible():
|
|
259
|
+
self._cm_completion_popup.hide()
|
|
260
|
+
|
|
261
|
+
def _cm_request_completion(self, multiline=False, full=False):
|
|
262
|
+
"""Send a completion request if one is not already in progress."""
|
|
263
|
+
self._ignore_next_completion = False
|
|
264
|
+
code = self.toPlainText()
|
|
265
|
+
cursor_pos = self.textCursor().position()
|
|
266
|
+
logger.info("Requesting completions at cursor_pos=%d, multiline=%s", cursor_pos, multiline)
|
|
267
|
+
|
|
268
|
+
self._cm_requested_cursor_pos = cursor_pos
|
|
269
|
+
self.send_worker_request(action='complete', code=code,
|
|
270
|
+
cursor_pos=cursor_pos, full=full,
|
|
271
|
+
multiline=multiline,
|
|
272
|
+
path=self.code_editor_file_path,
|
|
273
|
+
language=self.code_editor_language)
|
|
274
|
+
|
|
275
|
+
def _cm_request_calltip(self):
|
|
276
|
+
"""Send a calltip request."""
|
|
277
|
+
code = self.toPlainText()
|
|
278
|
+
cursor_pos = self.textCursor().position()
|
|
279
|
+
self._cm_requested_calltip_cursor_pos = cursor_pos
|
|
280
|
+
logger.info("Requesting calltip at cursor_pos=%d", cursor_pos)
|
|
281
|
+
self.send_worker_request(action='calltip', code=code,
|
|
282
|
+
cursor_pos=cursor_pos,
|
|
283
|
+
path=self.code_editor_file_path,
|
|
284
|
+
language=self.code_editor_language)
|
|
285
|
+
|
|
286
|
+
def _cm_hide_calltip(self):
|
|
287
|
+
"""Hide the calltip widget."""
|
|
288
|
+
self._cm_calltip_widget.hide()
|
|
289
|
+
|
|
290
|
+
def handle_worker_result(self, action, result):
|
|
291
|
+
"""Check for completion or calltip results from the external worker."""
|
|
292
|
+
super().handle_worker_result(action, result)
|
|
293
|
+
if action == 'complete':
|
|
294
|
+
logger.info("Handling 'complete' action with result")
|
|
295
|
+
self._cm_complete(**result)
|
|
296
|
+
elif action == 'calltip':
|
|
297
|
+
logger.info("Handling 'calltip' action with result")
|
|
298
|
+
self._cm_calltip(**result)
|
|
299
|
+
|
|
300
|
+
def _cm_insert_completion(self, completion):
|
|
301
|
+
"""
|
|
302
|
+
This function is called when a completion fragment is selected
|
|
303
|
+
in a code editor that is based on a QPlainTextEdit. The completion
|
|
304
|
+
should be inserted at the cursor position, but there are a few
|
|
305
|
+
things to consider:
|
|
306
|
+
|
|
307
|
+
If the completion overlaps with the text that follows the cursor,
|
|
308
|
+
this trailing text should be removed to avoid duplication.
|
|
309
|
+
|
|
310
|
+
For example, in the situation below, the ')' should not be duplicated.
|
|
311
|
+
The | indicates the cursor position.
|
|
312
|
+
|
|
313
|
+
Text: print('Hello|')
|
|
314
|
+
Completion: world')
|
|
315
|
+
Result: print('Hello world')
|
|
316
|
+
|
|
317
|
+
The same holds for the previous text:
|
|
318
|
+
|
|
319
|
+
Text: print('Hello|')
|
|
320
|
+
Completion: Hello world')
|
|
321
|
+
Result: print('Hello world')
|
|
322
|
+
|
|
323
|
+
Now, we also want to remove any partial overlap on the left side
|
|
324
|
+
(i.e., if the user has already typed part of the completion that
|
|
325
|
+
appears at the beginning of completion). For instance, if the
|
|
326
|
+
user has typed "Hell" and the completion is "Hello world')", we
|
|
327
|
+
don't want to insert "Hell" again.
|
|
328
|
+
|
|
329
|
+
An exception is when there is exactly one character of overlap on
|
|
330
|
+
the left. This can happen for genuine completions like "Hel" and
|
|
331
|
+
"lo", where the double "l" should be preserved.
|
|
332
|
+
|
|
333
|
+
This function handles both directions:
|
|
334
|
+
1. Finds overlap at the end of the text before the cursor.
|
|
335
|
+
2. Finds overlap at the beginning of the text after the cursor.
|
|
336
|
+
"""
|
|
337
|
+
cursor = self.textCursor()
|
|
338
|
+
doc_text = self.toPlainText()
|
|
339
|
+
current_pos = cursor.position()
|
|
340
|
+
|
|
341
|
+
# 1) Handle left-side overlap:
|
|
342
|
+
# If part of the completion is already typed before the cursor,
|
|
343
|
+
# we will skip that part of the completion.
|
|
344
|
+
before_text = doc_text[max(0, current_pos - len(completion)): current_pos]
|
|
345
|
+
left_overlap_size = 0
|
|
346
|
+
max_check_left = min(len(before_text), len(completion))
|
|
347
|
+
# Find the largest suffix of 'before_text' that matches
|
|
348
|
+
# the prefix of 'completion'
|
|
349
|
+
for i in range(max_check_left, 0, -1):
|
|
350
|
+
if before_text[-i:] == completion[:i]:
|
|
351
|
+
left_overlap_size = i
|
|
352
|
+
break
|
|
353
|
+
# Captures special case of exactly 1 character of left overlap, which
|
|
354
|
+
# can reflect words with double letters, such as Hel + lo = Hello,
|
|
355
|
+
# and not Helo.
|
|
356
|
+
if left_overlap_size == 1:
|
|
357
|
+
left_overlap_size = 0
|
|
358
|
+
# Create a new completion without what's already typed
|
|
359
|
+
new_completion = completion[left_overlap_size:]
|
|
360
|
+
|
|
361
|
+
# 2) Handle right-side overlap:
|
|
362
|
+
# If the text immediately after the cursor duplicates
|
|
363
|
+
# the new_completion, remove that overlap from the document.
|
|
364
|
+
after_text = doc_text[current_pos : current_pos + len(new_completion)]
|
|
365
|
+
right_overlap_size = 0
|
|
366
|
+
max_check_right = min(len(new_completion), len(after_text))
|
|
367
|
+
# Find the largest prefix of 'after_text' that matches
|
|
368
|
+
# the suffix of 'new_completion'
|
|
369
|
+
for i in range(max_check_right, 0, -1):
|
|
370
|
+
if new_completion[-i:] == after_text[:i]:
|
|
371
|
+
right_overlap_size = i
|
|
372
|
+
break
|
|
373
|
+
|
|
374
|
+
cursor.beginEditBlock()
|
|
375
|
+
if right_overlap_size > 0:
|
|
376
|
+
# Remove the overlapping segment from the document
|
|
377
|
+
cursor.setPosition(current_pos, QTextCursor.MoveAnchor)
|
|
378
|
+
cursor.setPosition(current_pos + right_overlap_size,
|
|
379
|
+
QTextCursor.KeepAnchor)
|
|
380
|
+
cursor.removeSelectedText()
|
|
381
|
+
|
|
382
|
+
# 3) Insert the final completion text
|
|
383
|
+
cursor.insertText(new_completion)
|
|
384
|
+
cursor.endEditBlock()
|
|
385
|
+
self.setTextCursor(cursor)
|
|
386
|
+
|
|
387
|
+
def _cm_complete(self, completions, cursor_pos, multiline, full):
|
|
388
|
+
"""Handle completion results from the worker."""
|
|
389
|
+
if self._ignore_next_completion:
|
|
390
|
+
logger.info("Ignoring completion results.")
|
|
391
|
+
self._ignore_next_completion = False
|
|
392
|
+
return
|
|
393
|
+
# Discard if cursor changed since the request
|
|
394
|
+
if cursor_pos != self.textCursor().position():
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
if not completions:
|
|
398
|
+
# Even there are no completions, we still want to get full completions
|
|
399
|
+
if not multiline and not full:
|
|
400
|
+
self._cm_full_completion_timer.start()
|
|
401
|
+
self._cm_completion_popup.hide()
|
|
402
|
+
|
|
403
|
+
if multiline:
|
|
404
|
+
if not completions:
|
|
405
|
+
return
|
|
406
|
+
logger.info("Inserting first multiline completion: '%s'", completions[0])
|
|
407
|
+
completion_text = completions[0]['completion']
|
|
408
|
+
cursor = self.textCursor()
|
|
409
|
+
start = cursor.position()
|
|
410
|
+
cursor.insertText(completion_text)
|
|
411
|
+
end = cursor.position()
|
|
412
|
+
# Highlight what was inserted
|
|
413
|
+
cursor.setPosition(start, QTextCursor.MoveAnchor)
|
|
414
|
+
cursor.setPosition(end, QTextCursor.KeepAnchor)
|
|
415
|
+
self.setTextCursor(cursor)
|
|
416
|
+
else:
|
|
417
|
+
logger.info("Showing completion popup with %d completions.", len(completions))
|
|
418
|
+
# stop hide completion timer if it is active
|
|
419
|
+
if self._cm_hide_completion_timer.isActive():
|
|
420
|
+
self._cm_hide_completion_timer.stop()
|
|
421
|
+
# show completions
|
|
422
|
+
self._cm_completion_popup.show_completions(completions)
|
|
423
|
+
if not full:
|
|
424
|
+
self._cm_full_completion_timer.start()
|
|
425
|
+
|
|
426
|
+
def _cm_calltip(self, signatures, cursor_pos):
|
|
427
|
+
"""
|
|
428
|
+
Called when the worker returns calltip signature info.
|
|
429
|
+
"""
|
|
430
|
+
# Discard if cursor changed since the request
|
|
431
|
+
if cursor_pos != self.textCursor().position():
|
|
432
|
+
logger.info(
|
|
433
|
+
"Discarding calltip because cursor changed (old=%d, new=%d).",
|
|
434
|
+
cursor_pos, self.textCursor().position())
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
if not signatures:
|
|
438
|
+
logger.info("No signature info returned.")
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
text = "\n\n".join(signatures)
|
|
442
|
+
self._cm_show_calltip(text)
|
|
443
|
+
|
|
444
|
+
def _cm_show_calltip(self, text):
|
|
445
|
+
"""
|
|
446
|
+
Display the calltip as a small persistent widget below the cursor line,
|
|
447
|
+
horizontally aligned with the cursor, so it doesn't obscure typed text.
|
|
448
|
+
"""
|
|
449
|
+
if self._cm_calltip_widget.isVisible():
|
|
450
|
+
logger.info("Calltip widget already visible, updating text.")
|
|
451
|
+
self._cm_calltip_widget.setText(text)
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
logger.info("Displaying calltip widget.")
|
|
455
|
+
self._cm_calltip_widget.setText(text)
|
|
456
|
+
|
|
457
|
+
# QPlainTextEdit provides cursorRect(), returning the bounding rect of
|
|
458
|
+
# the text cursor relative to the editor's viewport.
|
|
459
|
+
cr = self.cursorRect()
|
|
460
|
+
|
|
461
|
+
# Convert its bottom-left to global coordinates, then shift down
|
|
462
|
+
global_pos = self.mapToGlobal(cr.bottomLeft())
|
|
463
|
+
global_pos.setX(global_pos.x() + self.viewportMargins().left())
|
|
464
|
+
# global_pos.setY(global_pos.y() + cr.height())
|
|
465
|
+
|
|
466
|
+
self._cm_calltip_widget.move(global_pos)
|
|
467
|
+
self._cm_calltip_widget.show()
|
|
468
|
+
|
|
469
|
+
def closeEvent(self, event):
|
|
470
|
+
"""
|
|
471
|
+
Ensure the worker shuts down. Then let the next class in the MRO
|
|
472
|
+
handle the close event (which is typically QPlainTextEdit).
|
|
473
|
+
"""
|
|
474
|
+
logger.info("Closing editor, shutting down completion worker.")
|
|
475
|
+
self._cm_request_queue.put({'action': 'quit'})
|
|
476
|
+
self._cm_worker_process.join()
|
|
477
|
+
super().closeEvent(event)
|
|
478
|
+
logger.info("Editor closed, worker process joined.")
|
|
479
|
+
|
|
480
|
+
def update_theme(self):
|
|
481
|
+
super().update_theme()
|
|
482
|
+
self._cm_calltip_widget.apply_stylesheet()
|