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,239 @@
1
+ from qtpy.QtCore import Qt
2
+ from .. import python_utils, settings
3
+ import logging
4
+ logging.basicConfig(level=logging.INFO, force=True)
5
+
6
+
7
+ class PythonAutoIndent:
8
+ """
9
+ A mixin class to provide Python-specific auto-indentation,
10
+ suitable for mixing into a QPlainTextEdit subclass.
11
+
12
+ This version includes more "smart" indentation heuristics:
13
+ 1. Increase indentation for lines ending in ':' (block start).
14
+ 2. Increase indentation for an open bracket '([{', if it has no closing match
15
+ on the same line (simple heuristic).
16
+ 3. Attempt to align arguments if we detect a function call or definition
17
+ continuing onto the next line.
18
+ 4. Dedent if the new line starts with keywords like 'elif' or 'else'.
19
+ 5. Basic bracket matching to align closing brackets with the line holding
20
+ the corresponding open bracket (though for brevity we show a simple approach).
21
+ """
22
+
23
+ def keyPressEvent(self, event):
24
+ key = event.key()
25
+ modifiers = event.modifiers()
26
+ logging.info("keyPressEvent: key=%s, modifiers=%s", key, modifiers)
27
+
28
+ # Check for Tab/Shift+Tab indentation
29
+ if key == Qt.Key_Tab and modifiers == Qt.NoModifier:
30
+ logging.info("Tab pressed, indent code")
31
+ self.indent_code()
32
+ return
33
+ elif (key == Qt.Key_Tab and modifiers == Qt.ShiftModifier) or key == Qt.Key_Backtab:
34
+ logging.info("Shift+Tab pressed, dedent code")
35
+ self.dedent_code()
36
+ return
37
+
38
+ # Handle backspace in leading indentation
39
+ if key == Qt.Key_Backspace and modifiers == Qt.NoModifier:
40
+ if self._handle_backspace():
41
+ return
42
+
43
+ # Handle delete in leading indentation
44
+ if key == Qt.Key_Delete and modifiers == Qt.NoModifier:
45
+ if self._handle_delete():
46
+ return
47
+
48
+ # Check for Enter/Return
49
+ if key in (Qt.Key_Enter, Qt.Key_Return):
50
+ logging.info("Enter/Return pressed")
51
+ cursor = self.textCursor()
52
+ cursor.beginEditBlock()
53
+ code = self.toPlainText()[:cursor.position()]
54
+ new_indent = python_utils.python_auto_indent(code)
55
+ # Insert the computed indentation
56
+ super().keyPressEvent(event)
57
+ if new_indent > 0:
58
+ logging.info("Inserting %d spaces of indentation", new_indent)
59
+ self._insert_indentation(new_indent)
60
+ cursor.endEditBlock()
61
+ self.setTextCursor(cursor)
62
+ return
63
+
64
+ # Otherwise, default behavior
65
+ super().keyPressEvent(event)
66
+
67
+ def _handle_backspace(self):
68
+ """
69
+ If the cursor is in leading indentation, remove a 'tab chunk'
70
+ of spaces (settings.tab_width), otherwise let normal backspace happen.
71
+ Returns True if we handled it, False if not.
72
+ """
73
+ cursor = self.textCursor()
74
+ if not cursor.hasSelection():
75
+ block_text = cursor.block().text()
76
+ pos_in_block = cursor.positionInBlock()
77
+ leading_spaces = len(block_text) - len(block_text.lstrip(' '))
78
+
79
+ # Only if we're in the leading whitespace region:
80
+ if pos_in_block > 0 and pos_in_block <= leading_spaces:
81
+ # Figure out how many spaces to remove
82
+ remainder = pos_in_block % settings.tab_width
83
+ if remainder == 0:
84
+ remainder = settings.tab_width
85
+
86
+ remove_count = min(remainder, pos_in_block)
87
+ logging.info("Backspace in leading indentation, removing %d spaces", remove_count)
88
+
89
+ # Delete the chunk of spaces
90
+ for _ in range(remove_count):
91
+ cursor.deletePreviousChar()
92
+ return True
93
+ return False
94
+
95
+ def _handle_delete(self):
96
+ """
97
+ If the cursor is in leading indentation, remove a 'tab chunk'
98
+ of spaces (settings.tab_width) going forward, otherwise let normal delete happen.
99
+ Returns True if we handled it, False if not.
100
+ """
101
+ cursor = self.textCursor()
102
+ if not cursor.hasSelection():
103
+ block_text = cursor.block().text()
104
+ pos_in_block = cursor.positionInBlock()
105
+ leading_spaces = len(block_text) - len(block_text.lstrip(' '))
106
+
107
+ # Only if we're in the leading whitespace region:
108
+ if pos_in_block < leading_spaces:
109
+ # Figure out how many spaces remain in this "tab chunk"
110
+ chunk_end = ((pos_in_block // settings.tab_width) + 1) * settings.tab_width
111
+ remove_count = min(chunk_end - pos_in_block, leading_spaces - pos_in_block)
112
+ logging.info("Delete in leading indentation, removing %d spaces", remove_count)
113
+
114
+ for _ in range(remove_count):
115
+ cursor.deleteChar()
116
+ return True
117
+ return False
118
+
119
+
120
+ def _is_multiline_selection(self):
121
+ """
122
+ Returns True if selection spans more than one line, else False.
123
+ """
124
+ cursor = self.textCursor()
125
+ start = cursor.selectionStart()
126
+ end = cursor.selectionEnd()
127
+ start_block = self.document().findBlock(start).blockNumber()
128
+ end_block = self.document().findBlock(end).blockNumber()
129
+ return end_block > start_block
130
+
131
+ def indent_code(self):
132
+ """
133
+ Indent either the selected lines (if multi-line selection)
134
+ or just the current cursor position (if single-line or no selection).
135
+ All changes happen in a single undo block.
136
+ """
137
+ logging.info("Indent code triggered")
138
+ cursor = self.textCursor()
139
+ cursor.beginEditBlock()
140
+
141
+ if self._is_multiline_selection():
142
+ logging.info("Multi-line selection detected -> Indenting each line")
143
+ self._indent_selection()
144
+ else:
145
+ logging.info("Single-line (or no) selection -> Inserting indent at cursor")
146
+ # Insert spacing at the current cursor position
147
+ cursor.insertText(' ' * settings.tab_width)
148
+
149
+ cursor.endEditBlock()
150
+
151
+ def dedent_code(self):
152
+ """
153
+ Dedent either the selected lines (if multi-line selection)
154
+ or just the current line (if single-line or no selection).
155
+ All changes happen in a single undo block.
156
+ """
157
+ logging.info("Dedent code triggered")
158
+ cursor = self.textCursor()
159
+ cursor.beginEditBlock()
160
+
161
+ if self._is_multiline_selection():
162
+ logging.info("Multi-line selection detected -> Dedenting each line")
163
+ self._dedent_selection()
164
+ else:
165
+ logging.info("Single-line (or no) selection -> Removing indent from current line if possible")
166
+ line_start = cursor.block().position()
167
+ leading_spaces = 0
168
+ doc_text = cursor.block().text()
169
+ for ch in doc_text:
170
+ if ch == ' ':
171
+ leading_spaces += 1
172
+ else:
173
+ break
174
+
175
+ remove_spaces = min(settings.tab_width, leading_spaces)
176
+ cursor.setPosition(line_start)
177
+ for _ in range(remove_spaces):
178
+ self._delete_forward_if_space(cursor)
179
+
180
+ cursor.endEditBlock()
181
+
182
+ def _indent_selection(self):
183
+ """
184
+ Increase indent for each selected line by settings.tab_width spaces.
185
+ If no selection or selection on one line, that is handled outside.
186
+ """
187
+ logging.info("Indent selection (multi-line)")
188
+ cursor = self.textCursor()
189
+ start = cursor.selectionStart()
190
+ end = cursor.selectionEnd()
191
+
192
+ start_block = self.document().findBlock(start).blockNumber()
193
+ end_block = self.document().findBlock(end).blockNumber()
194
+
195
+ # Work line by line
196
+ for block_num in range(start_block, end_block + 1):
197
+ block = self.document().findBlockByNumber(block_num)
198
+ tmp_cursor = self.textCursor()
199
+ tmp_cursor.setPosition(block.position())
200
+ tmp_cursor.insertText(' ' * settings.tab_width)
201
+
202
+ def _dedent_selection(self):
203
+ """
204
+ Decrease indent for each selected line by settings.tab_width spaces,
205
+ ensuring no reduction below zero.
206
+ If no selection or selection on one line, that is handled outside.
207
+ """
208
+ logging.info("Dedent selection (multi-line)")
209
+ cursor = self.textCursor()
210
+ start = cursor.selectionStart()
211
+ end = cursor.selectionEnd()
212
+
213
+ start_block = self.document().findBlock(start).blockNumber()
214
+ end_block = self.document().findBlock(end).blockNumber()
215
+
216
+ for block_num in range(start_block, end_block + 1):
217
+ block = self.document().findBlockByNumber(block_num)
218
+ line_text = block.text()
219
+ leading_spaces = len(line_text) - len(line_text.lstrip(' '))
220
+
221
+ remove_spaces = min(settings.tab_width, leading_spaces)
222
+
223
+ tmp_cursor = self.textCursor()
224
+ tmp_cursor.setPosition(block.position())
225
+ for _ in range(remove_spaces):
226
+ self._delete_forward_if_space(tmp_cursor)
227
+
228
+ def _delete_forward_if_space(self, cursor):
229
+ """
230
+ Deletes one character if it is a space, used in dedent logic.
231
+ """
232
+ if cursor.document().characterAt(cursor.position()) == ' ':
233
+ logging.info("Deleting forward space during dedent")
234
+ cursor.deleteChar()
235
+
236
+ def _insert_indentation(self, indent_count):
237
+ """Insert the specified number of spaces at the cursor."""
238
+ logging.info("Inserting indentation: %d spaces", indent_count)
239
+ self.textCursor().insertText(' ' * indent_count)
@@ -0,0 +1,396 @@
1
+ import re
2
+ from qtpy.QtWidgets import QShortcut
3
+ from qtpy.QtGui import (
4
+ QSyntaxHighlighter, QTextCharFormat, QColor, QAction,
5
+ QTextCursor, QKeySequence, QTextDocument
6
+ )
7
+ from qtpy.QtCore import Qt
8
+ from .. import settings
9
+ from ..widgets.search_replace_frame import SearchReplaceFrame
10
+
11
+
12
+ class SearchReplaceHighlighter(QSyntaxHighlighter):
13
+ """
14
+ Simple syntax highlighter that highlights all matches of a given pattern.
15
+ Updated whenever the pattern or text changes.
16
+ """
17
+ def __init__(self, parent=None):
18
+ super().__init__(parent)
19
+ self._pattern = ""
20
+ self._use_regex = False
21
+ self._case_sensitive = False
22
+ self._whole_word = False
23
+
24
+ # Highlight style
25
+ self.highlight_format = QTextCharFormat()
26
+ self.highlight_format.setBackground(QColor(settings.search_replace_background))
27
+ self.highlight_format.setForeground(QColor(settings.search_replace_foreground))
28
+
29
+ def setSearchOptions(self, pattern, use_regex, case_sensitive, whole_word):
30
+ self._pattern = pattern
31
+ self._use_regex = use_regex
32
+ self._case_sensitive = case_sensitive
33
+ self._whole_word = whole_word
34
+ self.rehighlight() # Trigger a re-scan
35
+
36
+ def highlightBlock(self, text):
37
+ if not self._pattern:
38
+ return
39
+
40
+ # Build the pattern
41
+ pattern = self._pattern
42
+ flags = 0 if self._case_sensitive else re.IGNORECASE
43
+
44
+ if self._use_regex:
45
+ # If whole_word is asked for, add \b on both sides (can be tricky with punctuation).
46
+ if self._whole_word:
47
+ pattern = rf"\b{pattern}\b"
48
+ try:
49
+ regex = re.compile(pattern, flags)
50
+ except re.error:
51
+ return # invalid regex, just skip
52
+ else:
53
+ # Escape user text if not in regex mode
54
+ pattern = re.escape(pattern)
55
+ if self._whole_word:
56
+ pattern = rf"\b{pattern}\b"
57
+ regex = re.compile(pattern, flags)
58
+
59
+ for match in regex.finditer(text):
60
+ start = match.start()
61
+ length = match.end() - match.start()
62
+ self.setFormat(start, length, self.highlight_format)
63
+
64
+
65
+ class SearchReplace:
66
+ """
67
+ A mixin for QPlainTextEdit that floats a search/replace widget
68
+ at the top-right corner, highlights all matches, and supports
69
+ next/prev/replace operations. Automatically uses a single-line
70
+ selection as the search needle if available.
71
+
72
+ This version swaps out your 'original' syntax highlighter with
73
+ our search highlighter while the search widget is visible, so only
74
+ one QSyntaxHighlighter is attached at a time.
75
+ """
76
+
77
+ def __init__(self, *args, **kwargs):
78
+ """
79
+ The derived editor class must:
80
+ 1) Inherit from QPlainTextEdit + SearchReplace
81
+ 2) Call super().__init__() properly.
82
+ """
83
+ super().__init__(*args, **kwargs)
84
+ self._originalHighlighter = None # We'll store your existing syntax highlighter here
85
+ self._searchHighlighter = SearchReplaceHighlighter(None) # We'll attach/detach dynamically
86
+
87
+ self.setupSearchReplace()
88
+
89
+ def setSyntaxHighlighter(self, highlighter):
90
+ """
91
+ Call this to register your normal syntax highlighter.
92
+ We'll attach/detach it as needed.
93
+ """
94
+ self._originalHighlighter = highlighter
95
+ if highlighter:
96
+ highlighter.setDocument(self.document())
97
+
98
+ def setupSearchReplace(self):
99
+ """
100
+ Call this once in your QPlainTextEdit subclass's __init__
101
+ """
102
+ # Create the search/replace frame
103
+ self._searchFrame = SearchReplaceFrame(self)
104
+ self._searchFrame.setVisible(False)
105
+
106
+ # Connect signals
107
+ self._searchFrame.findNextRequested.connect(self.findNext)
108
+ self._searchFrame.findPrevRequested.connect(self.findPrev)
109
+ self._searchFrame.replaceOneRequested.connect(self.replaceOne)
110
+ self._searchFrame.replaceAllRequested.connect(self.replaceAll)
111
+
112
+ # Whenever user changes the find text or toggles checkboxes, re-highlight
113
+ self._searchFrame.findEdit.textChanged.connect(self.updateHighlighter)
114
+ self._searchFrame.caseBox.toggled.connect(self.updateHighlighter)
115
+ self._searchFrame.regexBox.toggled.connect(self.updateHighlighter)
116
+ self._searchFrame.wholeWordBox.toggled.connect(self.updateHighlighter)
117
+
118
+ # Shortcuts
119
+ findShortcut = QShortcut(QKeySequence.Find, self)
120
+ findShortcut.setContext(Qt.WidgetWithChildrenShortcut)
121
+ findShortcut.activated.connect(self.showSearchOnly)
122
+
123
+ replShortcut = QShortcut(QKeySequence.Replace, self)
124
+ replShortcut.setContext(Qt.WidgetWithChildrenShortcut)
125
+ replShortcut.activated.connect(self.showSearchReplace)
126
+
127
+ # Hide on Escape if wanted
128
+ self.escapeAction = QAction(self)
129
+ self.escapeShortcut = QShortcut(QKeySequence.Cancel, self)
130
+ self.escapeShortcut.setContext(Qt.WidgetWithChildrenShortcut)
131
+ self.escapeShortcut.activated.connect(self.hideSearch)
132
+ self.escapeShortcut.setEnabled(False)
133
+ self.escapeAction.triggered.connect(self.hideSearch)
134
+ self.escapeAction.setEnabled(False)
135
+ self.addAction(self.escapeAction)
136
+
137
+ def _showSearchReplace(self, search_only):
138
+ # If user has single-line selection, auto-populate
139
+ cursor = self.textCursor()
140
+ if cursor.hasSelection():
141
+ needle = cursor.selectedText().splitlines()
142
+ if len(needle) == 1:
143
+ needle = needle[0].strip()
144
+ self._searchFrame.findEdit.setText(needle)
145
+ else:
146
+ needle = self._searchFrame.findEdit.text()
147
+ self._updateMatchLabel(needle, self._find_flags())
148
+
149
+ if search_only:
150
+ self._searchFrame.showSearchOnly()
151
+ else:
152
+ self._searchFrame.showSearchReplace()
153
+ self._searchFrame.setVisible(True)
154
+ self._searchFrame.findEdit.setFocus()
155
+ self.updateSearchPosition()
156
+
157
+ # Swap out original highlighter for the search highlighter
158
+ self._swapToSearchHighlighter()
159
+ self.updateHighlighter()
160
+ self.escapeAction.setEnabled(True)
161
+ self.escapeShortcut.setEnabled(True)
162
+
163
+ def showSearchOnly(self):
164
+ self._showSearchReplace(search_only=True)
165
+
166
+ def showSearchReplace(self):
167
+ self._showSearchReplace(search_only=False)
168
+
169
+ def hideSearch(self):
170
+ self._searchFrame.setVisible(False)
171
+ self.setFocus()
172
+ self.escapeAction.setEnabled(False)
173
+ self.escapeShortcut.setEnabled(False)
174
+ # Revert to original highlighter
175
+ self._revertToOriginalHighlighter()
176
+
177
+ def resizeEvent(self, event):
178
+ """ Overridden to keep the search frame pinned top-right. """
179
+ super().resizeEvent(event)
180
+ self.updateSearchPosition()
181
+
182
+ def updateSearchPosition(self):
183
+ if self._searchFrame.isVisible():
184
+ # Pin to top-right with some margin
185
+ margin = 20
186
+ frame_width = self._searchFrame.width()
187
+ self._searchFrame.move(self.width() - frame_width - margin, margin)
188
+
189
+ def _swapToSearchHighlighter(self):
190
+ """
191
+ Temporarily remove the original syntax highlighter
192
+ and attach the search highlighter.
193
+ """
194
+ if self._originalHighlighter:
195
+ self._originalHighlighter.setDocument(None)
196
+ self._searchHighlighter.setDocument(self.document())
197
+
198
+ def _revertToOriginalHighlighter(self):
199
+ """
200
+ Detach the search highlighter and restore the original highlighter.
201
+ """
202
+ self._searchHighlighter.setDocument(None)
203
+ if self._originalHighlighter:
204
+ self._originalHighlighter.setDocument(self.document())
205
+
206
+ def _find_flags(self, forward=True):
207
+ flags = QTextDocument.FindFlag(0)
208
+ if self._searchFrame.caseBox.isChecked():
209
+ flags |= QTextDocument.FindFlag.FindCaseSensitively
210
+ if not forward:
211
+ flags |= QTextDocument.FindFlag.FindBackward
212
+ if self._searchFrame.wholeWordBox.isChecked():
213
+ flags |= QTextDocument.FindFlag.FindWholeWords
214
+ return flags
215
+
216
+ def findNext(self):
217
+ self._find(forward=True)
218
+
219
+ def findPrev(self):
220
+ self._find(forward=False)
221
+
222
+ def _find(self, forward=True):
223
+ flags = self._find_flags(forward=forward)
224
+ needle = self._searchFrame.findEdit.text()
225
+ if not needle:
226
+ self._searchFrame.matchCountLabel.clear()
227
+ return
228
+
229
+ # 1) Attempt to find next/prev match.
230
+ found = self.find(needle, flags)
231
+
232
+ # 2) If not found, do wrap-around by jumping to start or end.
233
+ if not found:
234
+ cursor = self.textCursor()
235
+ if forward:
236
+ cursor.movePosition(QTextCursor.Start)
237
+ else:
238
+ cursor.movePosition(QTextCursor.End)
239
+ self.setTextCursor(cursor)
240
+ self.find(needle, flags)
241
+
242
+ # 3) Regardless of found or wrap-around success, update label:
243
+ self._updateMatchLabel(needle, flags)
244
+
245
+ def _updateMatchLabel(self, needle, flags):
246
+ """
247
+ Counts how many total occurrences are in the document
248
+ and which occurrence the cursor is currently on. Then sets
249
+ "X of Y" in self._searchFrame.matchCountLabel, or clears
250
+ it if no matches are found.
251
+ """
252
+ if not needle:
253
+ self._searchFrame.matchCountLabel.clear()
254
+ return
255
+
256
+ # We'll remove any backward flag to count from top to bottom
257
+ # so we can do a standard forward pass for counting:
258
+ forward_flags = QTextDocument.FindFlag(flags & ~QTextDocument.FindBackward)
259
+
260
+ # Save the user's current cursor
261
+ saved_cursor = self.textCursor()
262
+
263
+ # Move a fresh cursor to the start
264
+ temp_cursor = QTextCursor(self.document())
265
+ temp_cursor.movePosition(QTextCursor.Start)
266
+
267
+ total_matches = 0
268
+ current_index = 0
269
+
270
+ # We'll store all match positions so we can see which is "current"
271
+ found_positions = []
272
+
273
+ # 1) Find all matches from top to bottom.
274
+ while True:
275
+ temp_cursor = self.document().find(needle, temp_cursor, forward_flags)
276
+ if temp_cursor.isNull():
277
+ break
278
+ found_positions.append((temp_cursor.position(), temp_cursor.anchor()))
279
+ total_matches += 1
280
+
281
+ if total_matches == 0:
282
+ # No matches at all
283
+ self._searchFrame.matchCountLabel.setText('0 of 0')
284
+ # Restore original cursor
285
+ self.setTextCursor(saved_cursor)
286
+ return
287
+
288
+ # 2) Determine which match is "current" by comparing
289
+ # our saved_cursor's position to the found positions:
290
+ current_pos = saved_cursor.position()
291
+ current_anchor = saved_cursor.anchor()
292
+
293
+ for idx, (pos, anch) in enumerate(found_positions, start=1):
294
+ if pos == current_pos and anch == current_anchor:
295
+ current_index = idx
296
+ break
297
+
298
+ if current_index == 0:
299
+ # Possibly just in wrap-around scenario; we can do extra logic
300
+ # or default to 1 (the last found match).
301
+ current_index = total_matches
302
+
303
+ # 3) Update the label text
304
+ self._searchFrame.matchCountLabel.setText(f"{current_index} of {total_matches}")
305
+
306
+ # 4) Restore original cursor
307
+ self.setTextCursor(saved_cursor)
308
+
309
+ def replaceOne(self):
310
+ needle = self._searchFrame.findEdit.text()
311
+ if not needle:
312
+ return
313
+
314
+ cursor = self.textCursor()
315
+ if cursor.hasSelection():
316
+ # Optionally verify the selected text is the current match. We'll skip that check here.
317
+ replacement_text = self._searchFrame.replaceEdit.text()
318
+ cursor.insertText(replacement_text)
319
+ self.setTextCursor(cursor)
320
+ self.findNext()
321
+ self.updateHighlighter()
322
+ self.set_modified(True)
323
+
324
+ def replaceAll(self):
325
+ needle = self._searchFrame.findEdit.text()
326
+ if not needle:
327
+ return
328
+
329
+ replacement = self._searchFrame.replaceEdit.text()
330
+
331
+ # Save cursor
332
+ saved_cursor = self.textCursor()
333
+ text = self.toPlainText()
334
+
335
+ # Build regex pattern
336
+ flags = 0 if self._searchFrame.caseBox.isChecked() else re.IGNORECASE
337
+ patt = needle
338
+ if not self._searchFrame.regexBox.isChecked():
339
+ patt = re.escape(patt)
340
+ if self._searchFrame.wholeWordBox.isChecked():
341
+ patt = rf"\b{patt}\b"
342
+
343
+ try:
344
+ compiled = re.compile(patt, flags)
345
+ except re.error:
346
+ return
347
+
348
+ new_text, num_replacements = compiled.subn(replacement, text)
349
+ self.setPlainText(new_text)
350
+
351
+ # After replace all, set the needle to the replacement text
352
+ self._searchFrame.findEdit.setText(replacement)
353
+
354
+ # Restore cursor
355
+ self.setTextCursor(saved_cursor)
356
+ self.updateHighlighter()
357
+ self.set_modified(True)
358
+
359
+ def updateHighlighter(self):
360
+ # When search widget is hidden, the search highlighter won't be attached
361
+ # So only do this if the widget is visible
362
+ if not self._searchFrame.isVisible():
363
+ return
364
+
365
+ find_text = self._searchFrame.findEdit.text()
366
+ use_regex = self._searchFrame.regexBox.isChecked()
367
+ case_sensitive = self._searchFrame.caseBox.isChecked()
368
+ whole_word = self._searchFrame.wholeWordBox.isChecked()
369
+
370
+ self._searchHighlighter.setSearchOptions(
371
+ find_text, use_regex, case_sensitive, whole_word
372
+ )
373
+
374
+ # Count total matches to enable/disable buttons
375
+ match_count = 0
376
+ if find_text:
377
+ flags = 0 if case_sensitive else re.IGNORECASE
378
+ pattern = find_text
379
+ if not use_regex:
380
+ pattern = re.escape(pattern)
381
+ if whole_word:
382
+ pattern = rf"\b{pattern}\b"
383
+ try:
384
+ compiled = re.compile(pattern, flags)
385
+ match_count = len(compiled.findall(self.toPlainText()))
386
+ except re.error:
387
+ match_count = 0
388
+
389
+ # Enable/disable buttons based on matches
390
+ has_matches = (match_count > 0)
391
+ self._searchFrame.findNextBtn.setEnabled(has_matches)
392
+ self._searchFrame.findPrevBtn.setEnabled(has_matches)
393
+ self._searchFrame.replaceBtn.setEnabled(has_matches)
394
+ self._searchFrame.replaceAllBtn.setEnabled(has_matches)
395
+ self._updateMatchLabel(self._searchFrame.findEdit.text(),
396
+ self._find_flags())