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