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,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()