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,515 @@
1
+ import os
2
+ import shutil
3
+ from pathlib import Path
4
+ import logging
5
+ from qtpy.QtWidgets import (
6
+ QDockWidget,
7
+ QTreeView,
8
+ QFileDialog,
9
+ QMenu,
10
+ QMessageBox,
11
+ QShortcut,
12
+ QInputDialog,
13
+ QWidget,
14
+ QVBoxLayout,
15
+ QCheckBox
16
+ )
17
+ from qtpy.QtCore import Qt, QDir, QModelIndex, QSortFilterProxyModel, QUrl, \
18
+ Signal
19
+ from qtpy.QtWidgets import QFileSystemModel
20
+ from qtpy.QtGui import QDesktopServices
21
+ from pathspec import PathSpec
22
+ from pathspec.patterns.gitwildmatch import GitWildMatchPattern
23
+ from .. import settings, themes
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ class LazyQFileSystemModel(QFileSystemModel):
28
+ """
29
+ A QFileSystemModel that only fetches children for 'expanded' paths.
30
+ This prevents eagerly creating inotify watchers for large, collapsed directories.
31
+ """
32
+ def __init__(self, parent=None):
33
+ super().__init__(parent)
34
+ self._expanded_paths = set()
35
+
36
+ def setRootPath(self, root):
37
+ idx = super().setRootPath(root)
38
+ # Ensure the root path is always considered expanded
39
+ if root:
40
+ self._expanded_paths.add(root)
41
+ # Force a fetch so we see the root directory contents
42
+ if idx.isValid() and super().canFetchMore(idx):
43
+ super().fetchMore(idx)
44
+ return idx
45
+
46
+ def notify_path_expanded(self, path):
47
+ self._expanded_paths.add(path)
48
+ # Manually trigger fetch
49
+ index = self.index(path)
50
+ if super().canFetchMore(index):
51
+ super().fetchMore(index)
52
+
53
+ def notify_path_collapsed(self, path):
54
+ if path in self._expanded_paths:
55
+ self._expanded_paths.remove(path)
56
+
57
+ def canFetchMore(self, index):
58
+ if not index.isValid():
59
+ return super().canFetchMore(index)
60
+ path = self.filePath(index)
61
+ # Only allow fetch if path is in expanded set
62
+ if path in self._expanded_paths:
63
+ return super().canFetchMore(index)
64
+ return False
65
+
66
+ def fetchMore(self, index):
67
+ if not index.isValid():
68
+ return super().fetchMore(index)
69
+ path = self.filePath(index)
70
+ # Only do the actual fetch for expanded paths
71
+ if path in self._expanded_paths:
72
+ return super().fetchMore(index)
73
+
74
+ class GitignoreFilterProxyModel(QSortFilterProxyModel):
75
+ """
76
+ A QSortFilterProxyModel that hides paths ignored by .gitignore (when enabled).
77
+ Parses .gitignore with pathspec. Also forwards hasChildren/fetchMore to the source
78
+ model so that folders may be expanded.
79
+ """
80
+ def __init__(self, parent=None):
81
+ super().__init__(parent)
82
+ self.gitignore_enabled = False
83
+ self.root_folder = None
84
+ self.pathspec = None
85
+
86
+ def set_root_folder(self, folder):
87
+ """
88
+ Load .gitignore (if present) from 'folder' and parse it into a PathSpec.
89
+ Keep the folder path for computing relative paths.
90
+ """
91
+ self.root_folder = folder
92
+ self.pathspec = None
93
+ if not self.gitignore_enabled:
94
+ return
95
+
96
+ gitignore_path = os.path.join(folder, '.gitignore')
97
+ if os.path.isfile(gitignore_path):
98
+ try:
99
+ with open(gitignore_path, 'r', encoding='utf-8') as f:
100
+ patterns = f.read().splitlines()
101
+ # Use GitWildMatchPattern to mimic .gitignore logic
102
+ self.pathspec = PathSpec.from_lines(GitWildMatchPattern, patterns)
103
+ except Exception as e:
104
+ logger.warning(f"Failed to parse .gitignore: {e}")
105
+
106
+ def filterAcceptsRow(self, source_row, source_parent):
107
+ # If not enabled or we have no pathspec, pass everything
108
+ if not self.gitignore_enabled or not self.pathspec:
109
+ return True
110
+
111
+ index = self.sourceModel().index(source_row, 0, source_parent)
112
+ if not index.isValid():
113
+ return True
114
+
115
+ source_model = self.sourceModel()
116
+ abs_path = source_model.filePath(index)
117
+ # Make sure our designated root folder is never hidden
118
+ if abs_path == self.root_folder:
119
+ return True
120
+ if not self.root_folder:
121
+ return True
122
+ # Ignore explicitly ignored folders
123
+ path_parts = Path(abs_path).parts
124
+ if any(ignored in path_parts for ignored in settings.ignored_folders):
125
+ return False
126
+ # Compute relative path from the repository root
127
+ rel_path = os.path.relpath(abs_path, self.root_folder)
128
+ # If matched by pathspec => it is ignored => filter out
129
+ return not self.pathspec.match_file(rel_path)
130
+
131
+ # ----------------------
132
+ # Overridden methods to ensure directories can still expand
133
+ # ----------------------
134
+
135
+ def hasChildren(self, parent):
136
+ """Delegate hasChildren to the source model so the tree can show expandable folders."""
137
+ source_index = self.mapToSource(parent)
138
+ return self.sourceModel().hasChildren(source_index)
139
+
140
+ def canFetchMore(self, parent):
141
+ """Ask the source model if it can fetch more items for lazy loading."""
142
+ source_index = self.mapToSource(parent)
143
+ return self.sourceModel().canFetchMore(source_index)
144
+
145
+ def fetchMore(self, parent):
146
+ """Delegate fetchMore to the source model so subfolders are properly loaded."""
147
+ source_index = self.mapToSource(parent)
148
+ self.sourceModel().fetchMore(source_index)
149
+
150
+ class ProjectExplorer(QDockWidget):
151
+
152
+ closed = Signal(object)
153
+
154
+ def __init__(self, editor_panel, root_path=None, parent=None):
155
+ super().__init__(os.path.basename(root_path), parent)
156
+ self._editor_panel = editor_panel
157
+
158
+ # Our local "clipboard" for cut/copy/paste
159
+ self._clipboard_operation = None # 'cut' or 'copy'
160
+ self._clipboard_source_path = None
161
+
162
+ # Underlying LazyQFileSystemModel
163
+ self._model = LazyQFileSystemModel(self)
164
+
165
+ # Add a filter proxy to hide items from .gitignore if enabled
166
+ self._filter_proxy = GitignoreFilterProxyModel(self)
167
+ self._filter_proxy.setSourceModel(self._model)
168
+
169
+ self._display_root = root_path or QDir.currentPath()
170
+
171
+ # Create a container widget and layout, so we can have the treeview + an optional checkbox
172
+ container_widget = QWidget(self)
173
+ layout = QVBoxLayout(container_widget)
174
+ layout.setContentsMargins(*themes.OUTER_CONTENT_MARGINS)
175
+
176
+ # Create our TreeView and attach the proxy model
177
+ self._tree_view = QTreeView(container_widget)
178
+ self._tree_view.setModel(self._filter_proxy)
179
+ layout.addWidget(self._tree_view)
180
+
181
+ # Connect expanded/collapsed signals through the proxy => model
182
+ self._tree_view.expanded.connect(self._on_expanded_proxy)
183
+ self._tree_view.collapsed.connect(self._on_collapsed_proxy)
184
+
185
+ # Optional: Hide columns other than the file name
186
+ self._set_single_column_view(True)
187
+
188
+ # Only if .gitignore exists in the root, create the checkbox
189
+ gitignore_path = os.path.join(self._display_root, '.gitignore')
190
+ if os.path.isfile(gitignore_path):
191
+ # Enabled by default
192
+ self._gitignore_checkbox = QCheckBox("Use .gitignore", container_widget)
193
+ self._gitignore_checkbox.setChecked(True)
194
+ # Connect toggling to set_gitignore_enabled
195
+ self._gitignore_checkbox.toggled.connect(self._toggle_gitignore)
196
+ layout.addWidget(self._gitignore_checkbox)
197
+ self._toggle_gitignore(True)
198
+ else:
199
+ self._toggle_gitignore(False)
200
+
201
+ # Configure QTreeView
202
+ self._tree_view.setContextMenuPolicy(Qt.CustomContextMenu)
203
+ self._tree_view.customContextMenuRequested.connect(self._show_context_menu)
204
+ self._tree_view.doubleClicked.connect(self._on_double_click)
205
+
206
+ # Set our container widget as the dock widget’s main widget
207
+ self.setWidget(container_widget)
208
+
209
+ def list_files(self) -> list[str]:
210
+ """
211
+ Returns a list of all non-ignored files under the display root,
212
+ applying the same .gitignore-based logic.
213
+ If there are more than max_files files, returns empty list.
214
+ """
215
+ if not self._display_root:
216
+ return []
217
+
218
+ results = []
219
+ gitignore_enabled = self._filter_proxy.gitignore_enabled
220
+ pathspec = self._filter_proxy.pathspec
221
+ # Recursively walk the filesystem from _display_root
222
+ for dirpath, dirnames, filenames in os.walk(self._display_root):
223
+ path_parts = Path(dirpath).parts
224
+ if any(ignored in path_parts for ignored in settings.ignored_folders):
225
+ continue
226
+ # Now gather files that are not ignored
227
+ for f in filenames:
228
+ abs_file = os.path.join(dirpath, f)
229
+ # Avoid filtering _display_root, though it shouldn't typically be a file
230
+ if abs_file == self._display_root:
231
+ continue
232
+
233
+ if gitignore_enabled and pathspec:
234
+ rel_file = os.path.relpath(abs_file, self._display_root)
235
+ # If pathspec matches => "ignored"
236
+ if pathspec.match_file(rel_file):
237
+ continue
238
+ results.append(abs_file)
239
+ if len(results) > settings.max_files:
240
+ logger.warning("Too many files in project")
241
+ return []
242
+
243
+ return results
244
+
245
+ @classmethod
246
+ def open_folder(cls, editor_panel, parent=None) -> object | None:
247
+ """
248
+ Shows a folder picker dialog to open a folder.
249
+ If a folder is selected, a ProjectExplorer instance is created and returned.
250
+ Otherwise None is returned.
251
+ """
252
+ selected_dir = QFileDialog.getExistingDirectory(
253
+ parent,
254
+ "Open Project Folder",
255
+ "",
256
+ QFileDialog.ShowDirsOnly
257
+ )
258
+ if not selected_dir:
259
+ return None
260
+ settings.current_folder = selected_dir
261
+ explorer = cls(editor_panel, root_path=selected_dir, parent=parent)
262
+ return explorer
263
+
264
+ def _toggle_gitignore(self, enabled):
265
+ """Toggles the .gitignore filter on or off and refreshes the model.
266
+ """
267
+ self._filter_proxy.gitignore_enabled = enabled
268
+ self._model.setRootPath(self._display_root)
269
+ self._filter_proxy.set_root_folder(self._display_root)
270
+ self._filter_proxy.invalidateFilter()
271
+ # Make the root folder visible and expanded
272
+ root_idx = self._model.index(self._display_root)
273
+ if root_idx.isValid():
274
+ proxy_root_index = self._filter_proxy.mapFromSource(root_idx)
275
+ self._tree_view.setRootIndex(proxy_root_index)
276
+ # Expand the root so it behaves like an expanded folder
277
+ self._tree_view.expand(proxy_root_index)
278
+
279
+ def _on_expanded_proxy(self, proxy_index):
280
+ """
281
+ Convert the proxy index to the source model index and notify LazyQFileSystemModel.
282
+ """
283
+ source_index = self._filter_proxy.mapToSource(proxy_index)
284
+ if source_index.isValid():
285
+ path = self._model.filePath(source_index)
286
+ self._model.notify_path_expanded(path)
287
+
288
+ def _on_collapsed_proxy(self, proxy_index):
289
+ """
290
+ Convert the proxy index to the source model index and notify LazyQFileSystemModel.
291
+ """
292
+ source_index = self._filter_proxy.mapToSource(proxy_index)
293
+ if source_index.isValid():
294
+ path = self._model.filePath(source_index)
295
+ self._model.notify_path_collapsed(path)
296
+
297
+ def _set_single_column_view(self, single_column=True):
298
+ """If single_column=True, show only the file name column with no header."""
299
+ if single_column:
300
+ # Hide columns 1,2,3 (Size, Type, Date Modified) and hide the header
301
+ self._tree_view.setHeaderHidden(True)
302
+ for col in range(1, 4):
303
+ self._tree_view.setColumnHidden(col, True)
304
+ else:
305
+ # Show all columns and show the header
306
+ self._tree_view.setHeaderHidden(False)
307
+ for col in range(1, 4):
308
+ self._tree_view.setColumnHidden(col, False)
309
+
310
+ def _on_double_click(self, proxy_index: QModelIndex):
311
+ """Open file on double-click if it's not a directory."""
312
+ source_index = self._filter_proxy.mapToSource(proxy_index)
313
+ path = self._model.filePath(source_index)
314
+ if os.path.isfile(path):
315
+ logger.info(f"Double-click opening file: {path}")
316
+ self._editor_panel.open_file(path)
317
+ else:
318
+ logger.info(f"Double-clicked on directory: {path}")
319
+
320
+ def _show_context_menu(self, pos):
321
+ """Build and show a context menu on right-click."""
322
+ proxy_index = self._tree_view.indexAt(pos)
323
+ source_index = self._filter_proxy.mapToSource(proxy_index)
324
+
325
+ menu = QMenu(self)
326
+
327
+ if not source_index.isValid():
328
+ # Clicked on empty space, same as before
329
+ new_file_action = menu.addAction("New File…")
330
+ new_folder_action = menu.addAction("New Folder…")
331
+ chosen_action = menu.exec_(self._tree_view.mapToGlobal(pos))
332
+
333
+ if chosen_action == new_file_action:
334
+ root_path = self._model.rootPath()
335
+ if os.path.isdir(root_path):
336
+ self._create_new_file(root_path)
337
+ elif chosen_action == new_folder_action:
338
+ root_path = self._model.rootPath()
339
+ if os.path.isdir(root_path):
340
+ self._create_new_folder(root_path)
341
+
342
+ else:
343
+ path = self._model.filePath(source_index)
344
+ is_file = os.path.isfile(path)
345
+
346
+ if is_file:
347
+ # Right-clicked on a file
348
+ open_action = menu.addAction("Open")
349
+ open_sys_action = menu.addAction("Open containing folder")
350
+ rename_action = menu.addAction("Rename…")
351
+ delete_action = menu.addAction("Delete")
352
+
353
+ menu.addSeparator()
354
+ cut_action = menu.addAction("Cut")
355
+ copy_action = menu.addAction("Copy")
356
+ paste_action = menu.addAction("Paste")
357
+ paste_action.setEnabled(self._clipboard_source_path is not None)
358
+
359
+ chosen_action = menu.exec_(self._tree_view.mapToGlobal(pos))
360
+ if chosen_action == open_action:
361
+ self._editor_panel.open_file(path)
362
+ elif chosen_action == open_sys_action:
363
+ # Open the file's parent folder
364
+ containing_folder = os.path.dirname(path)
365
+ try:
366
+ QDesktopServices.openUrl(QUrl.fromLocalFile(containing_folder))
367
+ except Exception as e:
368
+ QMessageBox.warning(self, "Error", f"Failed to open folder:\n{str(e)}")
369
+ elif chosen_action == rename_action:
370
+ self._rename_file_or_folder(path)
371
+ elif chosen_action == delete_action:
372
+ self._delete_file_or_folder(path)
373
+ elif chosen_action == cut_action:
374
+ self._clipboard_operation = 'cut'
375
+ self._clipboard_source_path = path
376
+ elif chosen_action == copy_action:
377
+ self._clipboard_operation = 'copy'
378
+ self._clipboard_source_path = path
379
+ elif chosen_action == paste_action:
380
+ self._paste_file_or_folder(path)
381
+
382
+ else:
383
+ # Right-clicked on a folder
384
+ open_action = menu.addAction("Open")
385
+ open_sys_action = menu.addAction("Open folder")
386
+ new_file_action = menu.addAction("New File…")
387
+ new_folder_action = menu.addAction("New Folder…")
388
+ rename_action = menu.addAction("Rename…")
389
+ delete_action = menu.addAction("Delete")
390
+
391
+ menu.addSeparator()
392
+ cut_action = menu.addAction("Cut")
393
+ copy_action = menu.addAction("Copy")
394
+ paste_action = menu.addAction("Paste")
395
+ paste_action.setEnabled(self._clipboard_source_path is not None)
396
+
397
+ chosen_action = menu.exec_(self._tree_view.mapToGlobal(pos))
398
+ if chosen_action == open_action:
399
+ # Expand in the tree if not already expanded
400
+ if proxy_index.isValid() and not self._tree_view.isExpanded(proxy_index):
401
+ self._tree_view.expand(proxy_index)
402
+ elif chosen_action == open_sys_action:
403
+ # Open the folder in system browser
404
+ try:
405
+ QDesktopServices.openUrl(QUrl.fromLocalFile(path))
406
+ except Exception as e:
407
+ QMessageBox.warning(self, "Error", f"Failed to open folder:\n{str(e)}")
408
+ elif chosen_action == new_file_action:
409
+ self._create_new_file(path)
410
+ elif chosen_action == new_folder_action:
411
+ self._create_new_folder(path)
412
+ elif chosen_action == rename_action:
413
+ self._rename_file_or_folder(path)
414
+ elif chosen_action == delete_action:
415
+ self._delete_file_or_folder(path)
416
+ elif chosen_action == cut_action:
417
+ self._clipboard_operation = 'cut'
418
+ self._clipboard_source_path = path
419
+ elif chosen_action == copy_action:
420
+ self._clipboard_operation = 'copy'
421
+ self._clipboard_source_path = path
422
+ elif chosen_action == paste_action:
423
+ self._paste_file_or_folder(path)
424
+
425
+ def _create_new_file(self, folder):
426
+ """Create a new file in the specified folder."""
427
+ if not os.path.isdir(folder):
428
+ return
429
+ file_name, ok = QFileDialog.getSaveFileName(self, "New File", folder)
430
+ if not ok or not file_name:
431
+ return
432
+ try:
433
+ with open(file_name, 'w', encoding='utf8') as f:
434
+ f.write("")
435
+ logger.info(f"Created file: {file_name}")
436
+ except Exception as e:
437
+ QMessageBox.warning(self, "Error", f"Failed to create file:\n{str(e)}")
438
+
439
+ def _create_new_folder(self, parent_folder):
440
+ """Creates a new subfolder inside 'parent_folder'."""
441
+ if not os.path.isdir(parent_folder):
442
+ return
443
+ folder_name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
444
+ if not ok or not folder_name:
445
+ return
446
+
447
+ new_path = os.path.join(parent_folder, folder_name)
448
+ try:
449
+ os.mkdir(new_path)
450
+ logger.info(f"Created folder: {new_path}")
451
+ except Exception as e:
452
+ QMessageBox.warning(self, "Error", f"Failed to create folder:\n{str(e)}")
453
+
454
+ def _rename_file_or_folder(self, path):
455
+ """Rename a file or folder via an input dialog, then rename in filesystem."""
456
+ base_dir = os.path.dirname(path)
457
+ old_name = os.path.basename(path)
458
+ new_name, ok = QFileDialog.getSaveFileName(self, "Rename", os.path.join(base_dir, old_name))
459
+ if not ok or not new_name:
460
+ return
461
+ try:
462
+ os.rename(path, new_name)
463
+ logger.info(f"Renamed {path} to {new_name}")
464
+ except Exception as e:
465
+ QMessageBox.warning(self, "Error", f"Failed to rename:\n{str(e)}")
466
+
467
+ def _delete_file_or_folder(self, path):
468
+ """Delete a file or entire folder."""
469
+ reply = QMessageBox.question(
470
+ self, "Delete",
471
+ f"Delete '{path}'?",
472
+ QMessageBox.Yes | QMessageBox.No,
473
+ QMessageBox.No
474
+ )
475
+ if reply == QMessageBox.No:
476
+ return
477
+ try:
478
+ if os.path.isfile(path):
479
+ os.remove(path)
480
+ else:
481
+ shutil.rmtree(path)
482
+ logger.info(f"Deleted: {path}")
483
+ except Exception as e:
484
+ QMessageBox.warning(self, "Error", f"Failed to delete:\n{str(e)}")
485
+
486
+ def _paste_file_or_folder(self, target_path):
487
+ """Paste files/folders from our local 'clipboard_operation' into target_path."""
488
+ if not self._clipboard_operation or not self._clipboard_source_path:
489
+ return
490
+ if os.path.isfile(target_path):
491
+ target_path = os.path.dirname(target_path)
492
+ if not os.path.isdir(target_path):
493
+ QMessageBox.warning(self, "Error", "Target is not a valid folder.")
494
+ return
495
+
496
+ src = self._clipboard_source_path
497
+ dst = os.path.join(target_path, os.path.basename(src))
498
+ try:
499
+ if self._clipboard_operation == 'copy':
500
+ if os.path.isdir(src):
501
+ shutil.copytree(src, dst)
502
+ else:
503
+ shutil.copy2(src, dst)
504
+ elif self._clipboard_operation == 'cut':
505
+ shutil.move(src, dst)
506
+ logger.info(f"{self._clipboard_operation.title()} '{src}' to '{dst}'")
507
+ except Exception as e:
508
+ QMessageBox.warning(self, "Error", f"Failed to {self._clipboard_operation}:\n{str(e)}")
509
+
510
+ self._clipboard_operation = None
511
+ self._clipboard_source_path = None
512
+
513
+ def closeEvent(self, event):
514
+ super().closeEvent(event)
515
+ self.closed.emit(self)
@@ -0,0 +1,162 @@
1
+ from qtpy.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout,
2
+ QCheckBox, QSpinBox, QLineEdit, QPushButton,
3
+ QGroupBox, QFormLayout, QScrollArea)
4
+ from .. import settings, themes
5
+ from ..widgets import Dock
6
+ import logging
7
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class SettingsWidget(QWidget):
12
+ """Implements a basic dynamic settings panel. The settings can be retrieved like
13
+ so:
14
+
15
+ ```
16
+ for setting, value in settings:
17
+ pass
18
+ ```
19
+
20
+ And settings can be dynamically changed, like so: `settings.my_setting = 10`.
21
+ There should also be a reset button that calls `settings.reset_to_defaults()`.
22
+
23
+ You can assume that the current value of a setting determines its type. That
24
+ is, if the value is an int, then it can only be changed to another int. Possible
25
+ types are int, bool and str.
26
+ """
27
+
28
+ def __init__(self, parent=None):
29
+ super().__init__(parent)
30
+ self.setup_ui()
31
+ self.load_settings()
32
+
33
+ # Connect to settings changes to update UI
34
+ settings.setting_changed.connect(self.on_setting_changed)
35
+
36
+ logger.info("Settings panel initialized")
37
+
38
+ def setup_ui(self):
39
+ """Create the basic UI structure"""
40
+ logger.debug("Setting up settings panel UI")
41
+ self.main_layout = QVBoxLayout(self)
42
+ self.main_layout.setContentsMargins(*themes.OUTER_CONTENT_MARGINS)
43
+
44
+ # Create a scroll area for the settings
45
+ self.scroll_area = QScrollArea()
46
+ self.scroll_area.setWidgetResizable(True)
47
+ self.scroll_widget = QWidget()
48
+ self.scroll_layout = QVBoxLayout(self.scroll_widget)
49
+ self.scroll_area.setWidget(self.scroll_widget)
50
+
51
+ # Add scroll area to main layout
52
+ self.main_layout.addWidget(self.scroll_area)
53
+
54
+ # Create a button layout at the bottom
55
+ self.button_layout = QHBoxLayout()
56
+ self.reset_button = QPushButton("Reset to Defaults")
57
+ self.reset_button.clicked.connect(self.reset_settings)
58
+ self.button_layout.addStretch()
59
+ self.button_layout.addWidget(self.reset_button)
60
+
61
+ self.main_layout.addLayout(self.button_layout)
62
+
63
+ # Dictionary to keep track of our UI elements for each setting
64
+ self.setting_widgets = {}
65
+
66
+ def load_settings(self):
67
+ """Load all settings and create UI elements for them"""
68
+ logger.info("Loading settings into UI")
69
+
70
+ # Get settings grouped by category
71
+ categories = settings.get_settings_by_category()
72
+
73
+ # Create a form for each category
74
+ for category, setting_names in categories.items():
75
+ if not setting_names:
76
+ continue
77
+
78
+ group_box = QGroupBox(category)
79
+ form_layout = QFormLayout(group_box)
80
+ form_layout.setContentsMargins(*themes.OUTER_CONTENT_MARGINS)
81
+
82
+ for setting_name in setting_names:
83
+ value = getattr(settings, setting_name)
84
+ widget = self.create_widget_for_setting(setting_name, value)
85
+
86
+ if widget:
87
+ form_layout.addRow(setting_name.replace('_', ' ').title() + ":", widget)
88
+ self.setting_widgets[setting_name] = widget
89
+
90
+ self.scroll_layout.addWidget(group_box)
91
+
92
+ self.scroll_layout.addStretch()
93
+
94
+ def create_widget_for_setting(self, setting_name, value):
95
+ """Create appropriate widget based on setting type"""
96
+ logger.debug(f"Creating widget for setting {setting_name} with value {value}")
97
+
98
+ if isinstance(value, bool):
99
+ widget = QCheckBox()
100
+ widget.setChecked(value)
101
+ widget.stateChanged.connect(lambda state, name=setting_name:
102
+ self.update_setting(name, bool(state)))
103
+
104
+ elif isinstance(value, int):
105
+ widget = QSpinBox()
106
+ widget.setRange(-9999, 9999) # Reasonable default range
107
+ widget.setValue(value)
108
+ widget.valueChanged.connect(lambda val, name=setting_name:
109
+ self.update_setting(name, val))
110
+
111
+ elif isinstance(value, str):
112
+ widget = QLineEdit(value)
113
+ widget.textChanged.connect(lambda text, name=setting_name:
114
+ self.update_setting(name, text))
115
+
116
+ else:
117
+ logger.warning(f"Unsupported setting type for {setting_name}: {type(value)}")
118
+ return None
119
+
120
+ return widget
121
+
122
+ def update_setting(self, setting_name, value):
123
+ """Update a setting value"""
124
+ current_value = getattr(settings, setting_name)
125
+
126
+ # Only update if the value has actually changed
127
+ # This avoids infinite loops when settings.setting_changed triggers
128
+ if current_value != value:
129
+ logger.info(f"Updating setting {setting_name} to {value}")
130
+ setattr(settings, setting_name, value)
131
+
132
+ def on_setting_changed(self, name, value):
133
+ """Called when a setting changes to update the UI"""
134
+ logger.debug(f"Setting changed: {name} = {value}")
135
+
136
+ # If we have a widget for this setting, update it
137
+ if name in self.setting_widgets:
138
+ widget = self.setting_widgets[name]
139
+
140
+ # Block signals to prevent infinite loops
141
+ widget.blockSignals(True)
142
+
143
+ if isinstance(widget, QCheckBox):
144
+ widget.setChecked(value)
145
+ elif isinstance(widget, QSpinBox):
146
+ widget.setValue(value)
147
+ elif isinstance(widget, QLineEdit):
148
+ widget.setText(value)
149
+
150
+ widget.blockSignals(False)
151
+
152
+ def reset_settings(self):
153
+ """Reset all settings to their default values"""
154
+ logger.info("Resetting all settings to defaults")
155
+ settings.reset_to_defaults()
156
+
157
+
158
+ class SettingsPanel(Dock):
159
+ def __init__(self, parent):
160
+ super().__init__('Settings', parent)
161
+ self.setObjectName("Settings")
162
+ self.setWidget(SettingsWidget(self))