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,33 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
from .. import utils
|
|
3
|
+
import logging
|
|
4
|
+
logging.basicConfig(level=logging.INFO, force=True)
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
editor_module_cache = {}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_editor(path=None, language=None, *args, **kwargs):
|
|
11
|
+
if language is None:
|
|
12
|
+
if path is None:
|
|
13
|
+
language = 'text'
|
|
14
|
+
else:
|
|
15
|
+
language = utils.guess_language_from_path(path)
|
|
16
|
+
# Load the editor module depending on the language. We store the
|
|
17
|
+
# imported module in a cache for efficiency
|
|
18
|
+
if language not in editor_module_cache:
|
|
19
|
+
try:
|
|
20
|
+
editor_module = importlib.import_module(
|
|
21
|
+
f".languages.{language}", package=__package__)
|
|
22
|
+
except ImportError:
|
|
23
|
+
from .languages import generic as editor_module
|
|
24
|
+
logger.info(f'failed to load editor module for {language}, falling back to generic')
|
|
25
|
+
else:
|
|
26
|
+
logger.info(f'loaded editor module for {language}')
|
|
27
|
+
editor_module_cache[language] = editor_module
|
|
28
|
+
else:
|
|
29
|
+
editor_module = editor_module_cache[language]
|
|
30
|
+
editor = editor_module.Editor(*args, language=language, **kwargs)
|
|
31
|
+
if path is not None:
|
|
32
|
+
editor.open_file(path)
|
|
33
|
+
return editor
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from qtpy.QtWidgets import QPlainTextEdit
|
|
2
|
+
from pyqt_code_editor.mixins import Complete, \
|
|
3
|
+
Theme, Zoom, LineNumber, Comment, \
|
|
4
|
+
AutoPair, SearchReplace, Base, Check, Shortcuts, FileLink, Symbols
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Editor(LineNumber, Zoom, Complete, AutoPair,
|
|
8
|
+
Comment, SearchReplace, FileLink,
|
|
9
|
+
Theme, Check, Shortcuts, Symbols, Base,
|
|
10
|
+
QPlainTextEdit):
|
|
11
|
+
pass
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from qtpy.QtWidgets import QPlainTextEdit
|
|
2
|
+
from pyqt_code_editor.mixins import Complete, \
|
|
3
|
+
HighlightSyntax, Zoom, LineNumber, Comment, \
|
|
4
|
+
SearchReplace, Base, Check, Shortcuts, FileLink, Symbols
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Editor(LineNumber, Zoom, Complete,
|
|
8
|
+
Comment, SearchReplace, FileLink,
|
|
9
|
+
HighlightSyntax, Check, Shortcuts, Symbols, Base,
|
|
10
|
+
QPlainTextEdit):
|
|
11
|
+
pass
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from qtpy.QtWidgets import QPlainTextEdit
|
|
2
|
+
from pyqt_code_editor.mixins import Complete, PythonAutoIndent, \
|
|
3
|
+
PythonAutoPair, Theme, Zoom, LineNumber, Comment, \
|
|
4
|
+
SearchReplace, Base, Check, Shortcuts, FileLink, Symbols, \
|
|
5
|
+
SmartBackspaceDelete, Execute
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Editor(LineNumber, Zoom, SmartBackspaceDelete, PythonAutoPair, Complete,
|
|
9
|
+
PythonAutoIndent, Comment, SearchReplace, FileLink, Execute,
|
|
10
|
+
Theme, Check, Shortcuts, Symbols, Base,
|
|
11
|
+
QPlainTextEdit):
|
|
12
|
+
|
|
13
|
+
code_editor_language = 'python'
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from qtpy.QtWidgets import QPlainTextEdit
|
|
2
|
+
from pyqt_code_editor.mixins import Complete, \
|
|
3
|
+
HighlightSyntax, Zoom, LineNumber, \
|
|
4
|
+
SearchReplace, Base, Check, Shortcuts, FileLink, Symbols
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Editor(LineNumber, Zoom, Complete, SearchReplace, FileLink,
|
|
8
|
+
HighlightSyntax, Check, Shortcuts, Base, QPlainTextEdit):
|
|
9
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from qtpy.QtWidgets import QApplication, QShortcut, QWidget, QHBoxLayout, QFileDialog
|
|
3
|
+
from qtpy.QtGui import QKeySequence
|
|
4
|
+
from qtpy.QtCore import Qt
|
|
5
|
+
from ..widgets import TabbedEditor, TabSplitter
|
|
6
|
+
from .. import settings, utils
|
|
7
|
+
import logging
|
|
8
|
+
logging.basicConfig(level=logging.INFO, force=True)
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EditorPanel(QWidget):
|
|
13
|
+
def __init__(self, parent=None, initial_path=None):
|
|
14
|
+
super().__init__(parent)
|
|
15
|
+
logger.info("EditorPanel initialized")
|
|
16
|
+
|
|
17
|
+
# Create a layout to hold the central_splitter as the sole widget
|
|
18
|
+
layout = QHBoxLayout(self)
|
|
19
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
20
|
+
|
|
21
|
+
self._active_editor = None
|
|
22
|
+
self._active_tab_widget = None
|
|
23
|
+
|
|
24
|
+
# This 'central_splitter' can support 1 or 2 children by our assumption
|
|
25
|
+
self.central_splitter = TabSplitter(self)
|
|
26
|
+
layout.addWidget(self.central_splitter)
|
|
27
|
+
|
|
28
|
+
self.initial_tab = self.create_tabbed_editor(initialize_empty=True)
|
|
29
|
+
self.central_splitter.addWidget(self.initial_tab)
|
|
30
|
+
|
|
31
|
+
self._split_h_shortcut = QShortcut(QKeySequence(settings.shortcut_split_horizontally), self)
|
|
32
|
+
self._split_h_shortcut.activated.connect(lambda: self.split(Qt.Horizontal))
|
|
33
|
+
|
|
34
|
+
self._split_v_shortcut = QShortcut(QKeySequence(settings.shortcut_split_vertically), self)
|
|
35
|
+
self._split_v_shortcut.activated.connect(lambda: self.split(Qt.Vertical))
|
|
36
|
+
|
|
37
|
+
self._open_shortcut = QShortcut(QKeySequence.Open, self)
|
|
38
|
+
self._open_shortcut.activated.connect(lambda: self.select_and_open_file())
|
|
39
|
+
|
|
40
|
+
self._new_shortcut = QShortcut(QKeySequence.New, self)
|
|
41
|
+
self._new_shortcut.activated.connect(lambda: self.open_file())
|
|
42
|
+
|
|
43
|
+
self._save_shortcut = QShortcut(QKeySequence.Save, self)
|
|
44
|
+
self._save_shortcut.activated.connect(lambda: self.save_file())
|
|
45
|
+
|
|
46
|
+
self._save_as_shortcut = QShortcut(QKeySequence.SaveAs, self)
|
|
47
|
+
self._save_as_shortcut.activated.connect(lambda: self.save_file_as())
|
|
48
|
+
|
|
49
|
+
def create_tabbed_editor(self, initial_path=None, initialize_empty=False):
|
|
50
|
+
"""Utility to create a TabbedEditor and connect lastTabClosed so we can remove splits."""
|
|
51
|
+
t = TabbedEditor(self)
|
|
52
|
+
if initial_path is not None or initialize_empty:
|
|
53
|
+
editor = t.add_code_editor(initial_path)
|
|
54
|
+
editor.received_focus.connect(self._keep_track_of_active_editor)
|
|
55
|
+
# Connect the lastTabClosed signal so that we can remove empty panels
|
|
56
|
+
t.lastTabClosed.connect(self.handle_tabbed_editor_empty)
|
|
57
|
+
t.tabCloseRequested.connect(self._relabel_tabs)
|
|
58
|
+
return t
|
|
59
|
+
|
|
60
|
+
def unsaved_changes(self):
|
|
61
|
+
return any(editor.modified for editor in self.central_splitter.editors())
|
|
62
|
+
|
|
63
|
+
def save_all_unsaved_changes(self):
|
|
64
|
+
for editor in self.central_splitter.editors():
|
|
65
|
+
if editor.modified:
|
|
66
|
+
editor.save_file()
|
|
67
|
+
|
|
68
|
+
def save_file(self):
|
|
69
|
+
active_editor = self.active_editor()
|
|
70
|
+
if active_editor is None:
|
|
71
|
+
return
|
|
72
|
+
active_editor.save_file()
|
|
73
|
+
|
|
74
|
+
def save_file_as(self):
|
|
75
|
+
active_editor = self.active_editor()
|
|
76
|
+
if active_editor is None:
|
|
77
|
+
return
|
|
78
|
+
active_editor.save_file_as()
|
|
79
|
+
|
|
80
|
+
def select_and_open_file(self):
|
|
81
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
82
|
+
self, "Open File", settings.current_folder, "All Files (*.*)")
|
|
83
|
+
if path:
|
|
84
|
+
settings.current_folder = os.path.dirname(path)
|
|
85
|
+
self.open_file(path)
|
|
86
|
+
|
|
87
|
+
def _keep_track_of_active_editor(self, editor):
|
|
88
|
+
logger.info(f"active editor = {editor}")
|
|
89
|
+
self._active_editor = editor
|
|
90
|
+
self._active_tab_widget = self._active_editor.parent().parent()
|
|
91
|
+
|
|
92
|
+
def open_file(self, path=None, line_number=None):
|
|
93
|
+
if path is not None:
|
|
94
|
+
# Don't allow the same file to be opened multiple times
|
|
95
|
+
for editor in self.central_splitter.editors():
|
|
96
|
+
if path == editor.code_editor_file_path:
|
|
97
|
+
# Make sure that the editor receives focus and is the
|
|
98
|
+
# current tab in its tab widget
|
|
99
|
+
tab_widget = editor.parent().parent()
|
|
100
|
+
tab_widget.setCurrentWidget(editor)
|
|
101
|
+
editor.setFocus()
|
|
102
|
+
if line_number is not None:
|
|
103
|
+
editor.jump_to_line(line_number)
|
|
104
|
+
return
|
|
105
|
+
logger.info(f"Opening file from {path}.")
|
|
106
|
+
active_tab_widget = self.active_tab_widget()
|
|
107
|
+
# If currently there's only a dummy Untitled tab open, close it so that
|
|
108
|
+
# we don't end up with lots of open dummy tabs
|
|
109
|
+
if active_tab_widget.count() == 1:
|
|
110
|
+
if active_tab_widget.tabText(0) == 'Untitled' \
|
|
111
|
+
and active_tab_widget.currentWidget().toPlainText() == '':
|
|
112
|
+
# Remove the dummy tab
|
|
113
|
+
active_tab_widget.removeTab(0)
|
|
114
|
+
editor = active_tab_widget.add_code_editor(path)
|
|
115
|
+
editor.received_focus.connect(self._keep_track_of_active_editor)
|
|
116
|
+
editor.setFocus()
|
|
117
|
+
if line_number is not None:
|
|
118
|
+
editor.jump_to_line(line_number)
|
|
119
|
+
self._relabel_tabs()
|
|
120
|
+
|
|
121
|
+
def _relabel_tabs(self):
|
|
122
|
+
"""Relabels all tabs so that file names are shortned but unique."""
|
|
123
|
+
paths = [editor.code_editor_file_path
|
|
124
|
+
for editor in self.central_splitter.editors()
|
|
125
|
+
if editor.code_editor_file_path is not None]
|
|
126
|
+
shortened_paths = utils.shorten_paths(paths)
|
|
127
|
+
synonyms = {path: shortened_path
|
|
128
|
+
for path, shortened_path
|
|
129
|
+
in zip(paths, shortened_paths)}
|
|
130
|
+
for tab_widget in self.central_splitter.tab_widgets():
|
|
131
|
+
tab_widget.relabel_tabs(synonyms)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def split(self, orientation):
|
|
135
|
+
"""
|
|
136
|
+
Splits the focused panel.
|
|
137
|
+
1) Find the widget in focus (or the main splitter if none).
|
|
138
|
+
2) Find the index in the parent's splitter while it's still parented.
|
|
139
|
+
3) Replace that index with a new splitter, containing the old widget + a fresh TabbedEditor.
|
|
140
|
+
4) Use setSizes() so neither widget ends up at size=0 in the new splitter.
|
|
141
|
+
"""
|
|
142
|
+
logger.info("Attempting to split with orientation '%s'",
|
|
143
|
+
"Horizontal" if orientation == Qt.Horizontal else "Vertical")
|
|
144
|
+
active_tab_widget = self.active_tab_widget()
|
|
145
|
+
if not active_tab_widget:
|
|
146
|
+
logger.info("No focused TabbedEditor found; cannot split.")
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
# The parent is probably the main TabSplitter or another splitter
|
|
150
|
+
parent_splitter = active_tab_widget.parentWidget()
|
|
151
|
+
logger.info("Focused TabbedEditor parent: %s", type(parent_splitter))
|
|
152
|
+
|
|
153
|
+
if not isinstance(parent_splitter, TabSplitter):
|
|
154
|
+
logger.info("The focused TabbedEditor is not inside a TabSplitter. Cannot split further.")
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
# STEP 1: find which index in parent_splitter the old widget occupies
|
|
158
|
+
idx_in_parent = None
|
|
159
|
+
for i in range(parent_splitter.count()):
|
|
160
|
+
if parent_splitter.widget(i) == active_tab_widget:
|
|
161
|
+
idx_in_parent = i
|
|
162
|
+
logger.info("Found old widget in parent's splitter at index %s", idx_in_parent)
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
if idx_in_parent is None:
|
|
166
|
+
logger.warning("Could not find the old widget in the parent's splitter; skipping split.")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
# STEP 2: create the new splitter and REPLACE at idx_in_parent
|
|
170
|
+
logger.info("Creating a new TabSplitter with orientation '%s'",
|
|
171
|
+
"Horizontal" if orientation == Qt.Horizontal else "Vertical")
|
|
172
|
+
new_splitter = TabSplitter(orientation, self)
|
|
173
|
+
|
|
174
|
+
logger.info("Replacing parent's child at index %s with new_splitter", idx_in_parent)
|
|
175
|
+
parent_splitter.replaceWidget(idx_in_parent, new_splitter)
|
|
176
|
+
|
|
177
|
+
# STEP 3: re-parent the old tab widget into the new splitter
|
|
178
|
+
logger.info("Re-parenting the focused TabbedEditor into the new_splitter")
|
|
179
|
+
active_tab_widget.setParent(new_splitter)
|
|
180
|
+
new_splitter.addWidget(active_tab_widget)
|
|
181
|
+
|
|
182
|
+
# STEP 4: create a second TabbedEditor in the new splitter
|
|
183
|
+
logger.info("Creating a second TabbedEditor to add to the new_splitter")
|
|
184
|
+
new_tabs = self.create_tabbed_editor()
|
|
185
|
+
new_code_editor = new_tabs.add_code_editor()
|
|
186
|
+
new_code_editor.received_focus.connect(self._keep_track_of_active_editor)
|
|
187
|
+
new_splitter.addWidget(new_tabs)
|
|
188
|
+
|
|
189
|
+
# Ensure both get some space (the default ratio might make one "invisible")
|
|
190
|
+
logger.info("Setting approximate sizes so neither splitter panel is collapsed")
|
|
191
|
+
active_tab_widget.show()
|
|
192
|
+
new_code_editor.setFocus()
|
|
193
|
+
|
|
194
|
+
def active_tab_widget(self):
|
|
195
|
+
"""Return the QTabWidget that currently has focus, or None if not found."""
|
|
196
|
+
if self._active_tab_widget and self._active_tab_widget.isVisible():
|
|
197
|
+
return self._active_tab_widget
|
|
198
|
+
# 1) Check if there's a currently focused widget in or under a QTabWidget.
|
|
199
|
+
focus_widget = QApplication.focusWidget()
|
|
200
|
+
while focus_widget:
|
|
201
|
+
if isinstance(focus_widget, TabbedEditor):
|
|
202
|
+
return focus_widget
|
|
203
|
+
focus_widget = focus_widget.parentWidget()
|
|
204
|
+
|
|
205
|
+
# 2) Fallback: if no QTabWidget is currently focused,
|
|
206
|
+
# look through all children of EditorPanel and return the first QTabWidget you find.
|
|
207
|
+
def find_first_tab_widget(widget):
|
|
208
|
+
if isinstance(widget, TabbedEditor):
|
|
209
|
+
return widget
|
|
210
|
+
for child in widget.findChildren(QWidget):
|
|
211
|
+
result = find_first_tab_widget(child)
|
|
212
|
+
if result is not None:
|
|
213
|
+
return result
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
return find_first_tab_widget(self)
|
|
217
|
+
|
|
218
|
+
def active_editor(self):
|
|
219
|
+
if self._active_editor and self._active_editor.isVisible():
|
|
220
|
+
return self._active_editor
|
|
221
|
+
active_tab_widget = self.active_tab_widget()
|
|
222
|
+
if active_tab_widget is None:
|
|
223
|
+
return None
|
|
224
|
+
return active_tab_widget.currentWidget()
|
|
225
|
+
|
|
226
|
+
def handle_tabbed_editor_empty(self, emptied_editor):
|
|
227
|
+
"""
|
|
228
|
+
Called when a TabbedEditor becomes empty.
|
|
229
|
+
1) Remove it from its parent splitter.
|
|
230
|
+
2) Flatten any splitters that now have only one child left.
|
|
231
|
+
"""
|
|
232
|
+
logger.info("handle_tabbed_editor_empty: %s", emptied_editor)
|
|
233
|
+
|
|
234
|
+
parent_splitter = emptied_editor.parentWidget()
|
|
235
|
+
if not isinstance(parent_splitter, TabSplitter):
|
|
236
|
+
logger.info("The emptied TabbedEditor has no TabSplitter parent, ignoring.")
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
# Find index and remove it from the splitter
|
|
240
|
+
idx_in_parent = None
|
|
241
|
+
for i in range(parent_splitter.count()):
|
|
242
|
+
if parent_splitter.widget(i) == emptied_editor:
|
|
243
|
+
idx_in_parent = i
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
if idx_in_parent is None:
|
|
247
|
+
logger.warning("handle_tabbed_editor_empty: cannot find editor in parent splitter")
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
# Remove from splitter
|
|
251
|
+
logger.info("Removing emptied TabbedEditor from splitter at index %s", idx_in_parent)
|
|
252
|
+
parent_splitter.replaceWidget(idx_in_parent, QWidget()) # put in a placeholder
|
|
253
|
+
emptied_editor.setParent(None)
|
|
254
|
+
emptied_editor.deleteLater()
|
|
255
|
+
|
|
256
|
+
# Now collapse upwards if the parent splitter has 1 child left
|
|
257
|
+
self._flatten_upwards(parent_splitter)
|
|
258
|
+
|
|
259
|
+
editors = self.central_splitter.editors()
|
|
260
|
+
# Make sure that an editor receives focus
|
|
261
|
+
if editors:
|
|
262
|
+
editors[0].setFocus()
|
|
263
|
+
# If all editors were closed, create a new empty tab
|
|
264
|
+
else:
|
|
265
|
+
logger.info('all editors closed, initializing empty tab')
|
|
266
|
+
self.initial_tab = self.create_tabbed_editor(initialize_empty=True)
|
|
267
|
+
self.central_splitter.addWidget(self.initial_tab)
|
|
268
|
+
|
|
269
|
+
def _flatten_upwards(self, splitter):
|
|
270
|
+
"""
|
|
271
|
+
If a splitter (other than the central splitter) ends up with only 1 child,
|
|
272
|
+
remove that splitter from its own parent and carry the child upward.
|
|
273
|
+
Then continue up the chain. If it's the central splitter with 1 child which
|
|
274
|
+
is itself a splitter, flatten that child into the central splitter.
|
|
275
|
+
"""
|
|
276
|
+
logger.info("_flatten_upwards called for splitter=%s", splitter)
|
|
277
|
+
|
|
278
|
+
# Remove placeholders (empty QWidget) from the splitter
|
|
279
|
+
# so we don't count them as real children
|
|
280
|
+
for i in range(splitter.count()):
|
|
281
|
+
w = splitter.widget(i)
|
|
282
|
+
# If the placeholder is a plain QWidget with no layout, remove it
|
|
283
|
+
if isinstance(w, QWidget) and not isinstance(w, TabSplitter) and not isinstance(w, TabbedEditor):
|
|
284
|
+
w.setParent(None)
|
|
285
|
+
w.deleteLater()
|
|
286
|
+
|
|
287
|
+
count = splitter.count()
|
|
288
|
+
logger.info("Splitter %s now has %d children", splitter, count)
|
|
289
|
+
|
|
290
|
+
# If there are 2 or more real children left, nothing to do
|
|
291
|
+
if count >= 2:
|
|
292
|
+
logger.info("Splitter has >=2 children, no flattening necessary.")
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
# If 0 left, remove this splitter from the parent's splitter as well
|
|
296
|
+
if count == 0:
|
|
297
|
+
logger.info("Splitter has 0 children, removing it from its parent.")
|
|
298
|
+
self._remove_splitter_from_parent(splitter)
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
# If exactly 1 child remains
|
|
302
|
+
single_child = splitter.widget(0)
|
|
303
|
+
logger.info("Splitter has exactly 1 child: %s", single_child)
|
|
304
|
+
|
|
305
|
+
# Is this the central splitter?
|
|
306
|
+
if splitter == self.central_splitter:
|
|
307
|
+
# The central splitter is allowed to have 1 child, but if that child is also a splitter
|
|
308
|
+
# that has 2 children, flatten it.
|
|
309
|
+
logger.info("Splitter is central_splitter, checking if we can flatten its single child.")
|
|
310
|
+
if isinstance(single_child, TabSplitter):
|
|
311
|
+
# We'll flatten that child's children directly into the central splitter
|
|
312
|
+
self._flatten_child_splitter(self.central_splitter, single_child)
|
|
313
|
+
else:
|
|
314
|
+
logger.info("Single child is not a TabSplitter. No further flattening.")
|
|
315
|
+
return
|
|
316
|
+
else:
|
|
317
|
+
# A normal splitter. We'll flatten it away.
|
|
318
|
+
logger.info("Splitter is a normal splitter with 1 child. Flattening up one level.")
|
|
319
|
+
self._remove_splitter_from_parent(splitter, single_child)
|
|
320
|
+
|
|
321
|
+
def _remove_splitter_from_parent(self, splitter, single_child=None):
|
|
322
|
+
"""
|
|
323
|
+
Removes splitter from its parent splitter. If single_child is given,
|
|
324
|
+
that child is re-parented to the parent's parent, preserving order.
|
|
325
|
+
"""
|
|
326
|
+
parent_splitter = splitter.parentWidget()
|
|
327
|
+
if not isinstance(parent_splitter, TabSplitter):
|
|
328
|
+
logger.info("Parent of this splitter is not another TabSplitter. Possibly main window. Doing nothing.")
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
# Find index of 'splitter' in parent_splitter
|
|
332
|
+
idx_in_parent = None
|
|
333
|
+
for i in range(parent_splitter.count()):
|
|
334
|
+
if parent_splitter.widget(i) == splitter:
|
|
335
|
+
idx_in_parent = i
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
if idx_in_parent is None:
|
|
339
|
+
logger.warning("Could not find splitter in its own parent?!")
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
# If we're flattening and have a single child, we remove 'splitter' and
|
|
343
|
+
# insert that single child in the same place
|
|
344
|
+
if single_child is not None:
|
|
345
|
+
logger.info("Flattening: re-parenting single_child %s up into parent splitter.", single_child)
|
|
346
|
+
parent_splitter.replaceWidget(idx_in_parent, single_child)
|
|
347
|
+
single_child.setParent(parent_splitter)
|
|
348
|
+
else:
|
|
349
|
+
# If 0 children left, just remove the splitter
|
|
350
|
+
parent_splitter.replaceWidget(idx_in_parent, QWidget())
|
|
351
|
+
splitter.setParent(None)
|
|
352
|
+
splitter.deleteLater()
|
|
353
|
+
|
|
354
|
+
# Now see if that parent splitter is left with 1 or 0 children after removing
|
|
355
|
+
self._flatten_upwards(parent_splitter)
|
|
356
|
+
|
|
357
|
+
def _flatten_child_splitter(self, parent_splitter, child_splitter):
|
|
358
|
+
"""
|
|
359
|
+
Flatten child_splitter by removing it from parent_splitter and
|
|
360
|
+
re-parenting its children directly into parent_splitter.
|
|
361
|
+
"""
|
|
362
|
+
logger.info("Flattening child splitter %s in the central splitter %s", child_splitter, parent_splitter)
|
|
363
|
+
|
|
364
|
+
# Find child_splitter index in parent_splitter
|
|
365
|
+
idx_in_parent = None
|
|
366
|
+
for i in range(parent_splitter.count()):
|
|
367
|
+
if parent_splitter.widget(i) == child_splitter:
|
|
368
|
+
idx_in_parent = i
|
|
369
|
+
break
|
|
370
|
+
if idx_in_parent is None:
|
|
371
|
+
logger.warning("Could not find child splitter in parent splitter")
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
# We'll gather child_splitter's children
|
|
375
|
+
child_widgets = []
|
|
376
|
+
for i in range(child_splitter.count()):
|
|
377
|
+
w = child_splitter.widget(i)
|
|
378
|
+
child_widgets.append(w)
|
|
379
|
+
|
|
380
|
+
# Replace the child_splitter with something temporary first
|
|
381
|
+
parent_splitter.replaceWidget(idx_in_parent, QWidget())
|
|
382
|
+
child_splitter.setParent(None)
|
|
383
|
+
|
|
384
|
+
# Now, re-add those children directly to the parent_splitter
|
|
385
|
+
for w in child_widgets:
|
|
386
|
+
w.setParent(parent_splitter)
|
|
387
|
+
parent_splitter.addWidget(w)
|
|
388
|
+
|
|
389
|
+
# Give the parent splitter the same orientation as the child, because
|
|
390
|
+
# the child should visually replace the parent
|
|
391
|
+
parent_splitter.setOrientation(child_splitter.orientation())
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# Delete old splitter
|
|
395
|
+
child_splitter.deleteLater()
|
|
396
|
+
|
|
397
|
+
# Finally, see if that leaves parent_splitter with 1 or 0 children, and flatten if needed
|
|
398
|
+
self._flatten_upwards(parent_splitter)
|
|
399
|
+
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import multiprocessing
|
|
3
|
+
from qtpy.QtWidgets import QDockWidget, QWidget, QVBoxLayout, QHBoxLayout, \
|
|
4
|
+
QLabel, QLineEdit, QCheckBox, QPushButton, QTreeWidget, QTreeWidgetItem, \
|
|
5
|
+
QAbstractItemView, QShortcut
|
|
6
|
+
from qtpy.QtCore import Qt, QTimer, Signal
|
|
7
|
+
from qtpy.QtGui import QKeySequence
|
|
8
|
+
|
|
9
|
+
def search_in_files_worker(files, search_text, case_sensitive, whole_word, regex, output_queue):
|
|
10
|
+
"""
|
|
11
|
+
Runs in a separate process; scans 'files' for 'search_text' and
|
|
12
|
+
posts partial results to 'output_queue'.
|
|
13
|
+
"""
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
flags = 0
|
|
17
|
+
pattern_text = search_text
|
|
18
|
+
if not case_sensitive:
|
|
19
|
+
flags = re.IGNORECASE
|
|
20
|
+
if whole_word:
|
|
21
|
+
# a naive approach with word boundaries
|
|
22
|
+
pattern_text = r"\b" + pattern_text + r"\b"
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
compiled_pattern = re.compile(pattern_text, flags=flags) if regex else re.compile(re.escape(pattern_text), flags=flags)
|
|
26
|
+
|
|
27
|
+
for file_path in files:
|
|
28
|
+
matches_for_file = []
|
|
29
|
+
try:
|
|
30
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
31
|
+
for i, line in enumerate(f):
|
|
32
|
+
if compiled_pattern.search(line):
|
|
33
|
+
matches_for_file.append((i+1, line.rstrip("\n")))
|
|
34
|
+
except Exception:
|
|
35
|
+
# Just skip unreadable files or handle differently
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
if matches_for_file:
|
|
39
|
+
# Post partial result
|
|
40
|
+
output_queue.put(("found", file_path, matches_for_file))
|
|
41
|
+
except Exception as e:
|
|
42
|
+
# If something goes drastically wrong, we can post an error
|
|
43
|
+
output_queue.put(("error", str(e)))
|
|
44
|
+
|
|
45
|
+
# Signal we are done
|
|
46
|
+
output_queue.put(("done", None))
|
|
47
|
+
|
|
48
|
+
class FindInFiles(QDockWidget):
|
|
49
|
+
|
|
50
|
+
open_file_requested = Signal(str, int)
|
|
51
|
+
|
|
52
|
+
def __init__(self, files_list, parent=None, needle=None):
|
|
53
|
+
super().__init__("Find in Files", parent)
|
|
54
|
+
self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
|
|
55
|
+
|
|
56
|
+
self._files = files_list[:] # store a copy
|
|
57
|
+
self._search_process = None
|
|
58
|
+
self._output_queue = None
|
|
59
|
+
|
|
60
|
+
central = QWidget(self)
|
|
61
|
+
layout = QVBoxLayout(central)
|
|
62
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
63
|
+
self.setWidget(central)
|
|
64
|
+
|
|
65
|
+
# Search Controls
|
|
66
|
+
controls_layout = QHBoxLayout()
|
|
67
|
+
layout.addLayout(controls_layout)
|
|
68
|
+
|
|
69
|
+
self.searchInput = QLineEdit(self)
|
|
70
|
+
self.searchInput.setPlaceholderText("Search …")
|
|
71
|
+
if needle is not None:
|
|
72
|
+
self.searchInput.setText(needle)
|
|
73
|
+
self.searchInput.returnPressed.connect(self._start_search)
|
|
74
|
+
self.caseBox = QCheckBox("Case", self)
|
|
75
|
+
self.wholeWordBox = QCheckBox("Word", self)
|
|
76
|
+
self.regexBox = QCheckBox("Regex", self)
|
|
77
|
+
self.searchBtn = QPushButton("Search", self)
|
|
78
|
+
|
|
79
|
+
controls_layout.addWidget(self.searchInput)
|
|
80
|
+
controls_layout.addWidget(self.caseBox)
|
|
81
|
+
controls_layout.addWidget(self.wholeWordBox)
|
|
82
|
+
controls_layout.addWidget(self.regexBox)
|
|
83
|
+
controls_layout.addWidget(self.searchBtn)
|
|
84
|
+
controls_layout.setContentsMargins(0, 0, 0, 0)
|
|
85
|
+
|
|
86
|
+
# Tree Widget for results
|
|
87
|
+
self.resultsTree = QTreeWidget(self)
|
|
88
|
+
self.resultsTree.setHeaderLabels(["File / Line", "Preview"])
|
|
89
|
+
self.resultsTree.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
90
|
+
layout.addWidget(self.resultsTree)
|
|
91
|
+
|
|
92
|
+
self.searchBtn.clicked.connect(self._start_search)
|
|
93
|
+
self.resultsTree.itemClicked.connect(self._on_item_clicked)
|
|
94
|
+
|
|
95
|
+
# Timer to poll queue
|
|
96
|
+
self._timer = QTimer(self)
|
|
97
|
+
self._timer.timeout.connect(self._poll_worker)
|
|
98
|
+
|
|
99
|
+
self._timer.setInterval(300) # every 300 ms
|
|
100
|
+
self._timer.start()
|
|
101
|
+
|
|
102
|
+
def showEvent(self, event):
|
|
103
|
+
"""Override showEvent to set focus when widget is shown"""
|
|
104
|
+
super().showEvent(event)
|
|
105
|
+
self.searchInput.setFocus()
|
|
106
|
+
|
|
107
|
+
def _start_search(self):
|
|
108
|
+
# Kill any existing worker first
|
|
109
|
+
if self._search_process and self._search_process.is_alive():
|
|
110
|
+
self._search_process.terminate()
|
|
111
|
+
|
|
112
|
+
# Clear old results
|
|
113
|
+
self.resultsTree.clear()
|
|
114
|
+
|
|
115
|
+
# Prepare
|
|
116
|
+
self._output_queue = multiprocessing.Queue()
|
|
117
|
+
args = (
|
|
118
|
+
self._files,
|
|
119
|
+
self.searchInput.text(),
|
|
120
|
+
self.caseBox.isChecked(),
|
|
121
|
+
self.wholeWordBox.isChecked(),
|
|
122
|
+
self.regexBox.isChecked(),
|
|
123
|
+
self._output_queue
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Start process
|
|
127
|
+
self._search_process = multiprocessing.Process(target=search_in_files_worker, args=args)
|
|
128
|
+
self._search_process.start()
|
|
129
|
+
|
|
130
|
+
def _poll_worker(self):
|
|
131
|
+
if self._search_process is not None:
|
|
132
|
+
# Check if the process crashed
|
|
133
|
+
if not self._search_process.is_alive() and self._output_queue.empty():
|
|
134
|
+
# Worker done or crashed with nothing reported
|
|
135
|
+
self._search_process = None
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Grab everything from the queue
|
|
139
|
+
while not self._output_queue.empty():
|
|
140
|
+
msg = self._output_queue.get()
|
|
141
|
+
if not msg:
|
|
142
|
+
continue
|
|
143
|
+
kind = msg[0]
|
|
144
|
+
|
|
145
|
+
if kind == "found":
|
|
146
|
+
# we have file + list of (line_num, text)
|
|
147
|
+
file_path = msg[1]
|
|
148
|
+
lines = msg[2]
|
|
149
|
+
self._add_file_matches(file_path, lines)
|
|
150
|
+
elif kind == "error":
|
|
151
|
+
# The worker had an internal error
|
|
152
|
+
error_msg = msg[1]
|
|
153
|
+
# You can log or show a message box
|
|
154
|
+
elif kind == "done":
|
|
155
|
+
# done means the worker finished scanning
|
|
156
|
+
self._search_process = None
|
|
157
|
+
|
|
158
|
+
def _add_file_matches(self, file_path, lines):
|
|
159
|
+
# Create a top-level item for file
|
|
160
|
+
topItem = QTreeWidgetItem(self.resultsTree)
|
|
161
|
+
topItem.setText(0, os.path.basename(file_path))
|
|
162
|
+
topItem.setText(1, file_path) # store path in second column
|
|
163
|
+
topItem.setData(0, Qt.UserRole, ("file", file_path))
|
|
164
|
+
|
|
165
|
+
for (line_no, preview) in lines:
|
|
166
|
+
child = QTreeWidgetItem(topItem)
|
|
167
|
+
child.setText(0, str(line_no))
|
|
168
|
+
child.setText(1, preview)
|
|
169
|
+
child.setData(0, Qt.UserRole, ("line", file_path, line_no))
|
|
170
|
+
|
|
171
|
+
topItem.setExpanded(True)
|
|
172
|
+
|
|
173
|
+
def _on_item_clicked(self, item, column):
|
|
174
|
+
user_data = item.data(0, Qt.UserRole)
|
|
175
|
+
if not user_data:
|
|
176
|
+
return
|
|
177
|
+
kind = user_data[0]
|
|
178
|
+
|
|
179
|
+
if kind == "file":
|
|
180
|
+
path = user_data[1]
|
|
181
|
+
self._open_file(path, line_no=0)
|
|
182
|
+
elif kind == "line":
|
|
183
|
+
path = user_data[1]
|
|
184
|
+
line_no = user_data[2]
|
|
185
|
+
self._open_file(path, line_no)
|
|
186
|
+
|
|
187
|
+
def _open_file(self, path, line_no=0):
|
|
188
|
+
"""
|
|
189
|
+
Dummy method: In production, integrate with your editor logic.
|
|
190
|
+
"""
|
|
191
|
+
print(f"Opening file: {path}, line {line_no}")
|
|
192
|
+
self.open_file_requested.emit(path, line_no)
|
|
193
|
+
|
|
194
|
+
def closeEvent(self, event):
|
|
195
|
+
# Terminate worker process, if any
|
|
196
|
+
if self._search_process and self._search_process.is_alive():
|
|
197
|
+
self._search_process.terminate()
|
|
198
|
+
self._search_process = None
|
|
199
|
+
super().closeEvent(event)
|