Coverage for openhcs/pyqt_gui/windows/file_browser_window.py: 0.0%
238 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
1"""
2File Browser Window for PyQt6
4File and directory browser dialog with filtering and selection capabilities.
5Uses hybrid approach: extracted business logic + clean PyQt6 UI.
6"""
8import logging
9from typing import Optional, Callable, List
10from pathlib import Path
11from enum import Enum
13from PyQt6.QtWidgets import (
14 QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
15 QTreeView, QLineEdit, QComboBox, QFrame, QWidget,
16 QSplitter, QListWidget, QListWidgetItem
17)
18from PyQt6.QtGui import QFileSystemModel
19from PyQt6.QtCore import Qt, QDir, pyqtSignal, QModelIndex
20from PyQt6.QtGui import QFont
22from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
23logger = logging.getLogger(__name__)
26class BrowserMode(Enum):
27 """Browser mode enumeration (extracted from Textual version)."""
28 LOAD = "load"
29 SAVE = "save"
32class SelectionMode(Enum):
33 """Selection mode enumeration (extracted from Textual version)."""
34 FILES_ONLY = "files_only"
35 DIRECTORIES_ONLY = "directories_only"
36 FILES_AND_DIRECTORIES = "files_and_directories"
39class FileBrowserWindow(QDialog):
40 """
41 PyQt6 File Browser Window.
43 File and directory browser with filtering, selection, and navigation.
44 Preserves all business logic from Textual version with clean PyQt6 UI.
45 """
47 # Signals
48 file_selected = pyqtSignal(list) # List of selected paths
49 selection_cancelled = pyqtSignal()
51 def __init__(self, initial_path: Optional[Path] = None,
52 mode: BrowserMode = BrowserMode.LOAD,
53 selection_mode: SelectionMode = SelectionMode.FILES_ONLY,
54 filter_extensions: Optional[List[str]] = None,
55 title: str = "File Browser",
56 on_result_callback: Optional[Callable] = None, color_scheme: Optional[PyQt6ColorScheme] = None,
57 parent=None):
58 """
59 Initialize the file browser window.
61 Args:
62 initial_path: Initial directory path
63 mode: Browser mode (load/save)
64 selection_mode: Selection mode (files/directories/both)
65 filter_extensions: List of file extensions to filter
66 title: Window title
67 on_result_callback: Callback for selection result
68 parent: Parent widget
69 """
70 super().__init__(parent)
72 # Initialize color scheme
73 self.color_scheme = color_scheme or PyQt6ColorScheme()
75 # Business logic state (extracted from Textual version)
76 self.initial_path = initial_path or Path.home()
77 self.mode = mode
78 self.selection_mode = selection_mode
79 self.filter_extensions = filter_extensions or []
80 self.on_result_callback = on_result_callback
82 # Current state
83 self.current_path = self.initial_path
84 self.selected_paths: List[Path] = []
86 # UI components
87 self.file_model: Optional[QFileSystemModel] = None
88 self.tree_view: Optional[QTreeView] = None
89 self.path_edit: Optional[QLineEdit] = None
90 self.filter_combo: Optional[QComboBox] = None
91 self.filename_edit: Optional[QLineEdit] = None
93 # Setup UI
94 self.setup_ui(title)
95 self.setup_connections()
96 self.navigate_to_path(self.initial_path)
98 logger.debug(f"File browser window initialized (mode={mode.value})")
100 def setup_ui(self, title: str):
101 """Setup the user interface."""
102 self.setWindowTitle(title)
103 self.setModal(True)
104 self.setMinimumSize(700, 500)
105 self.resize(900, 600)
107 layout = QVBoxLayout(self)
108 layout.setSpacing(10)
110 # Header with path navigation
111 header_frame = self.create_header()
112 layout.addWidget(header_frame)
114 # Main content area
115 content_splitter = self.create_content_area()
116 layout.addWidget(content_splitter)
118 # Footer with filename input (for save mode)
119 if self.mode == BrowserMode.SAVE:
120 footer_frame = self.create_footer()
121 layout.addWidget(footer_frame)
123 # Button panel
124 button_panel = self.create_button_panel()
125 layout.addWidget(button_panel)
127 # Set styling
128 self.setStyleSheet(f"""
129 QDialog {{
130 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)};
131 color: white;
132 }}
133 QTreeView {{
134 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
135 color: white;
136 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
137 border-radius: 3px;
138 selection-background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
139 }}
140 QLineEdit, QComboBox {{
141 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)};
142 color: white;
143 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_light)};
144 border-radius: 3px;
145 padding: 5px;
146 }}
147 """)
149 def create_header(self) -> QWidget:
150 """
151 Create the header with path navigation.
153 Returns:
154 Widget containing navigation controls
155 """
156 frame = QFrame()
157 frame.setFrameStyle(QFrame.Shape.Box)
158 frame.setStyleSheet(f"""
159 QFrame {{
160 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
161 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
162 border-radius: 3px;
163 padding: 8px;
164 }}
165 """)
167 layout = QHBoxLayout(frame)
169 # Path label
170 path_label = QLabel("Path:")
171 path_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_secondary)}; font-weight: bold;")
172 layout.addWidget(path_label)
174 # Path edit
175 self.path_edit = QLineEdit()
176 self.path_edit.setText(str(self.current_path))
177 self.path_edit.returnPressed.connect(self.on_path_entered)
178 layout.addWidget(self.path_edit)
180 # Up button
181 up_button = QPushButton("↑ Up")
182 up_button.setMaximumWidth(60)
183 up_button.clicked.connect(self.navigate_up)
184 up_button.setStyleSheet(f"""
185 QPushButton {{
186 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)};
187 color: white;
188 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_light)};
189 border-radius: 3px;
190 padding: 5px;
191 }}
192 QPushButton:hover {{
193 background-color: {self.color_scheme.to_hex(self.color_scheme.button_hover_bg)};
194 }}
195 """)
196 layout.addWidget(up_button)
198 # Filter combo
199 filter_label = QLabel("Filter:")
200 filter_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_secondary)}; font-weight: bold;")
201 layout.addWidget(filter_label)
203 self.filter_combo = QComboBox()
204 self.filter_combo.addItem("All Files (*)", "*")
206 # Add extension filters
207 if self.filter_extensions:
208 for ext in self.filter_extensions:
209 self.filter_combo.addItem(f"{ext.upper()} Files (*{ext})", f"*{ext}")
211 self.filter_combo.currentTextChanged.connect(self.on_filter_changed)
212 layout.addWidget(self.filter_combo)
214 return frame
216 def create_content_area(self) -> QWidget:
217 """
218 Create the main content area with file tree.
220 Returns:
221 Widget containing file browser
222 """
223 splitter = QSplitter(Qt.Orientation.Horizontal)
225 # File system model and tree view
226 self.file_model = QFileSystemModel()
227 self.file_model.setRootPath(str(self.current_path))
229 # Set name filters based on selection mode
230 self.update_name_filters()
232 self.tree_view = QTreeView()
233 self.tree_view.setModel(self.file_model)
234 self.tree_view.setRootIndex(self.file_model.index(str(self.current_path)))
236 # Configure tree view
237 self.tree_view.setSelectionMode(QTreeView.SelectionMode.ExtendedSelection)
238 self.tree_view.setSortingEnabled(True)
239 self.tree_view.sortByColumn(0, Qt.SortOrder.AscendingOrder)
241 # Hide unnecessary columns
242 self.tree_view.hideColumn(1) # Size
243 self.tree_view.hideColumn(2) # Type
244 self.tree_view.hideColumn(3) # Date Modified
246 splitter.addWidget(self.tree_view)
248 # Selection info panel
249 info_panel = self.create_selection_info_panel()
250 splitter.addWidget(info_panel)
252 # Set splitter proportions
253 splitter.setSizes([600, 200])
255 return splitter
257 def create_selection_info_panel(self) -> QWidget:
258 """
259 Create the selection information panel.
261 Returns:
262 Widget showing selection details
263 """
264 frame = QFrame()
265 frame.setFrameStyle(QFrame.Shape.Box)
266 frame.setStyleSheet(f"""
267 QFrame {{
268 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
269 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
270 border-radius: 3px;
271 padding: 5px;
272 }}
273 """)
275 layout = QVBoxLayout(frame)
277 # Selection label
278 selection_label = QLabel("Selection:")
279 selection_label.setFont(QFont("Arial", 10, QFont.Weight.Bold))
280 selection_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};")
281 layout.addWidget(selection_label)
283 # Selection list
284 self.selection_list = QListWidget()
285 self.selection_list.setStyleSheet(f"""
286 QListWidget {{
287 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)};
288 color: white;
289 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_light)};
290 border-radius: 3px;
291 }}
292 """)
293 layout.addWidget(self.selection_list)
295 return frame
297 def create_footer(self) -> QWidget:
298 """
299 Create the footer with filename input (save mode only).
301 Returns:
302 Widget containing filename input
303 """
304 frame = QFrame()
305 frame.setFrameStyle(QFrame.Shape.Box)
306 frame.setStyleSheet(f"""
307 QFrame {{
308 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
309 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
310 border-radius: 3px;
311 padding: 8px;
312 }}
313 """)
315 layout = QHBoxLayout(frame)
317 # Filename label
318 filename_label = QLabel("Filename:")
319 filename_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_secondary)}; font-weight: bold;")
320 layout.addWidget(filename_label)
322 # Filename edit
323 self.filename_edit = QLineEdit()
324 self.filename_edit.setPlaceholderText("Enter filename...")
325 layout.addWidget(self.filename_edit)
327 return frame
329 def create_button_panel(self) -> QWidget:
330 """
331 Create the button panel.
333 Returns:
334 Widget containing action buttons
335 """
336 panel = QFrame()
337 panel.setFrameStyle(QFrame.Shape.Box)
338 panel.setStyleSheet(f"""
339 QFrame {{
340 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
341 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
342 border-radius: 3px;
343 padding: 10px;
344 }}
345 """)
347 layout = QHBoxLayout(panel)
348 layout.addStretch()
350 # Cancel button
351 cancel_button = QPushButton("Cancel")
352 cancel_button.setMinimumWidth(80)
353 cancel_button.clicked.connect(self.cancel_selection)
354 cancel_button.setStyleSheet(f"""
355 QPushButton {{
356 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)};
357 color: white;
358 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.status_error)};
359 border-radius: 3px;
360 padding: 8px;
361 }}
362 QPushButton:hover {{
363 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)};
364 }}
365 """)
366 layout.addWidget(cancel_button)
368 # Select/Open button
369 action_text = "Save" if self.mode == BrowserMode.SAVE else "Open"
370 self.select_button = QPushButton(action_text)
371 self.select_button.setMinimumWidth(80)
372 self.select_button.clicked.connect(self.confirm_selection)
373 self.select_button.setStyleSheet(f"""
374 QPushButton {{
375 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
376 color: white;
377 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
378 border-radius: 3px;
379 padding: 8px;
380 }}
381 QPushButton:hover {{
382 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
383 }}
384 """)
385 layout.addWidget(self.select_button)
387 return panel
389 def setup_connections(self):
390 """Setup signal/slot connections."""
391 if self.tree_view:
392 self.tree_view.selectionModel().selectionChanged.connect(self.on_selection_changed)
393 self.tree_view.doubleClicked.connect(self.on_item_double_clicked)
395 def update_name_filters(self):
396 """Update file model name filters based on current settings."""
397 if not self.file_model:
398 return
400 filters = []
402 # Add extension filters
403 if self.filter_extensions:
404 for ext in self.filter_extensions:
405 filters.append(f"*{ext}")
407 # If no specific filters, show all files
408 if not filters:
409 filters = ["*"]
411 self.file_model.setNameFilters(filters)
413 # Set filter mode based on selection mode
414 if self.selection_mode == SelectionMode.DIRECTORIES_ONLY:
415 self.file_model.setFilter(QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot)
416 else:
417 self.file_model.setFilter(QDir.Filter.AllEntries | QDir.Filter.NoDotAndDotDot)
419 def navigate_to_path(self, path: Path):
420 """
421 Navigate to specific path.
423 Args:
424 path: Path to navigate to
425 """
426 if path.exists():
427 self.current_path = path
429 if self.file_model and self.tree_view:
430 self.file_model.setRootPath(str(path))
431 self.tree_view.setRootIndex(self.file_model.index(str(path)))
433 if self.path_edit:
434 self.path_edit.setText(str(path))
436 logger.debug(f"Navigated to: {path}")
438 def navigate_up(self):
439 """Navigate to parent directory."""
440 parent_path = self.current_path.parent
441 if parent_path != self.current_path: # Not at root
442 self.navigate_to_path(parent_path)
444 def on_path_entered(self):
445 """Handle manual path entry."""
446 path_text = self.path_edit.text().strip()
447 if path_text:
448 try:
449 path = Path(path_text)
450 if path.exists():
451 self.navigate_to_path(path)
452 else:
453 self.path_edit.setText(str(self.current_path)) # Reset to current
454 except Exception as e:
455 logger.warning(f"Invalid path entered: {e}")
456 self.path_edit.setText(str(self.current_path)) # Reset to current
458 def on_filter_changed(self, filter_text: str):
459 """Handle filter changes."""
460 self.update_name_filters()
462 def on_selection_changed(self):
463 """Handle tree view selection changes."""
464 if not self.tree_view:
465 return
467 selected_indexes = self.tree_view.selectionModel().selectedIndexes()
468 self.selected_paths = []
470 for index in selected_indexes:
471 if index.column() == 0: # Only process name column
472 file_path = Path(self.file_model.filePath(index))
473 self.selected_paths.append(file_path)
475 # Update selection list
476 self.update_selection_list()
478 # Update button state
479 self.select_button.setEnabled(len(self.selected_paths) > 0)
481 def on_item_double_clicked(self, index: QModelIndex):
482 """Handle double-click on items."""
483 file_path = Path(self.file_model.filePath(index))
485 if file_path.is_dir():
486 # Navigate into directory
487 self.navigate_to_path(file_path)
488 else:
489 # Select file and confirm
490 self.selected_paths = [file_path]
491 self.confirm_selection()
493 def update_selection_list(self):
494 """Update the selection list display."""
495 self.selection_list.clear()
497 for path in self.selected_paths:
498 item = QListWidgetItem(path.name)
499 item.setToolTip(str(path))
500 self.selection_list.addItem(item)
502 def confirm_selection(self):
503 """Confirm the current selection."""
504 if self.mode == BrowserMode.SAVE and self.filename_edit:
505 # For save mode, use filename from input
506 filename = self.filename_edit.text().strip()
507 if filename:
508 save_path = self.current_path / filename
509 self.selected_paths = [save_path]
510 elif not self.selected_paths:
511 from PyQt6.QtWidgets import QMessageBox
512 QMessageBox.warning(self, "No Filename", "Please enter a filename or select a file.")
513 return
515 if not self.selected_paths:
516 from PyQt6.QtWidgets import QMessageBox
517 QMessageBox.warning(self, "No Selection", "Please select a file or directory.")
518 return
520 # Emit signal and call callback
521 self.file_selected.emit([str(path) for path in self.selected_paths])
523 if self.on_result_callback:
524 self.on_result_callback(self.selected_paths)
526 self.accept()
527 logger.debug(f"Selection confirmed: {[str(p) for p in self.selected_paths]}")
529 def cancel_selection(self):
530 """Cancel the selection."""
531 self.selection_cancelled.emit()
532 self.reject()
533 logger.debug("Selection cancelled")
536# Convenience function for opening file browser
537def open_file_browser_window(initial_path: Optional[Path] = None,
538 mode: BrowserMode = BrowserMode.LOAD,
539 selection_mode: SelectionMode = SelectionMode.FILES_ONLY,
540 filter_extensions: Optional[List[str]] = None,
541 title: str = "File Browser",
542 on_result_callback: Optional[Callable] = None, color_scheme: Optional[PyQt6ColorScheme] = None,
543 parent=None):
544 """
545 Open file browser window.
547 Args:
548 initial_path: Initial directory path
549 mode: Browser mode (load/save)
550 selection_mode: Selection mode (files/directories/both)
551 filter_extensions: List of file extensions to filter
552 title: Window title
553 on_result_callback: Callback for selection result
554 parent: Parent widget
556 Returns:
557 Dialog result
558 """
559 browser = FileBrowserWindow(
560 initial_path=initial_path,
561 mode=mode,
562 selection_mode=selection_mode,
563 filter_extensions=filter_extensions,
564 title=title,
565 on_result_callback=on_result_callback,
566 parent=parent
567 )
568 return browser.exec()