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