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