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

1""" 

2File Browser Window for PyQt6 

3 

4File and directory browser dialog with filtering and selection capabilities. 

5Uses hybrid approach: extracted business logic + clean PyQt6 UI. 

6""" 

7 

8import logging 

9from typing import Optional, Callable, List 

10from pathlib import Path 

11from enum import Enum 

12 

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 

21 

22from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

23logger = logging.getLogger(__name__) 

24 

25 

26class BrowserMode(Enum): 

27 """Browser mode enumeration (extracted from Textual version).""" 

28 LOAD = "load" 

29 SAVE = "save" 

30 

31 

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" 

37 

38 

39class FileBrowserWindow(QDialog): 

40 """ 

41 PyQt6 File Browser Window. 

42  

43 File and directory browser with filtering, selection, and navigation. 

44 Preserves all business logic from Textual version with clean PyQt6 UI. 

45 """ 

46 

47 # Signals 

48 file_selected = pyqtSignal(list) # List of selected paths 

49 selection_cancelled = pyqtSignal() 

50 

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. 

60  

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) 

71 

72 # Initialize color scheme 

73 self.color_scheme = color_scheme or PyQt6ColorScheme() 

74 

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 

81 

82 # Current state 

83 self.current_path = self.initial_path 

84 self.selected_paths: List[Path] = [] 

85 

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 

92 

93 # Setup UI 

94 self.setup_ui(title) 

95 self.setup_connections() 

96 self.navigate_to_path(self.initial_path) 

97 

98 logger.debug(f"File browser window initialized (mode={mode.value})") 

99 

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) 

106 

107 layout = QVBoxLayout(self) 

108 layout.setSpacing(10) 

109 

110 # Header with path navigation 

111 header_frame = self.create_header() 

112 layout.addWidget(header_frame) 

113 

114 # Main content area 

115 content_splitter = self.create_content_area() 

116 layout.addWidget(content_splitter) 

117 

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) 

122 

123 # Button panel 

124 button_panel = self.create_button_panel() 

125 layout.addWidget(button_panel) 

126 

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

148 

149 def create_header(self) -> QWidget: 

150 """ 

151 Create the header with path navigation. 

152  

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

166 

167 layout = QHBoxLayout(frame) 

168 

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) 

173 

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) 

179 

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) 

197 

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) 

202 

203 self.filter_combo = QComboBox() 

204 self.filter_combo.addItem("All Files (*)", "*") 

205 

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}") 

210 

211 self.filter_combo.currentTextChanged.connect(self.on_filter_changed) 

212 layout.addWidget(self.filter_combo) 

213 

214 return frame 

215 

216 def create_content_area(self) -> QWidget: 

217 """ 

218 Create the main content area with file tree. 

219  

220 Returns: 

221 Widget containing file browser 

222 """ 

223 splitter = QSplitter(Qt.Orientation.Horizontal) 

224 

225 # File system model and tree view 

226 self.file_model = QFileSystemModel() 

227 self.file_model.setRootPath(str(self.current_path)) 

228 

229 # Set name filters based on selection mode 

230 self.update_name_filters() 

231 

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

235 

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) 

240 

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 

245 

246 splitter.addWidget(self.tree_view) 

247 

248 # Selection info panel 

249 info_panel = self.create_selection_info_panel() 

250 splitter.addWidget(info_panel) 

251 

252 # Set splitter proportions 

253 splitter.setSizes([600, 200]) 

254 

255 return splitter 

256 

257 def create_selection_info_panel(self) -> QWidget: 

258 """ 

259 Create the selection information panel. 

260  

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

274 

275 layout = QVBoxLayout(frame) 

276 

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) 

282 

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) 

294 

295 return frame 

296 

297 def create_footer(self) -> QWidget: 

298 """ 

299 Create the footer with filename input (save mode only). 

300  

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

314 

315 layout = QHBoxLayout(frame) 

316 

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) 

321 

322 # Filename edit 

323 self.filename_edit = QLineEdit() 

324 self.filename_edit.setPlaceholderText("Enter filename...") 

325 layout.addWidget(self.filename_edit) 

326 

327 return frame 

328 

329 def create_button_panel(self) -> QWidget: 

330 """ 

331 Create the button panel. 

332  

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

346 

347 layout = QHBoxLayout(panel) 

348 layout.addStretch() 

349 

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) 

367 

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) 

386 

387 return panel 

388 

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) 

394 

395 def update_name_filters(self): 

396 """Update file model name filters based on current settings.""" 

397 if not self.file_model: 

398 return 

399 

400 filters = [] 

401 

402 # Add extension filters 

403 if self.filter_extensions: 

404 for ext in self.filter_extensions: 

405 filters.append(f"*{ext}") 

406 

407 # If no specific filters, show all files 

408 if not filters: 

409 filters = ["*"] 

410 

411 self.file_model.setNameFilters(filters) 

412 

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) 

418 

419 def navigate_to_path(self, path: Path): 

420 """ 

421 Navigate to specific path. 

422  

423 Args: 

424 path: Path to navigate to 

425 """ 

426 if path.exists(): 

427 self.current_path = path 

428 

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

432 

433 if self.path_edit: 

434 self.path_edit.setText(str(path)) 

435 

436 logger.debug(f"Navigated to: {path}") 

437 

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) 

443 

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 

457 

458 def on_filter_changed(self, filter_text: str): 

459 """Handle filter changes.""" 

460 self.update_name_filters() 

461 

462 def on_selection_changed(self): 

463 """Handle tree view selection changes.""" 

464 if not self.tree_view: 

465 return 

466 

467 selected_indexes = self.tree_view.selectionModel().selectedIndexes() 

468 self.selected_paths = [] 

469 

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) 

474 

475 # Update selection list 

476 self.update_selection_list() 

477 

478 # Update button state 

479 self.select_button.setEnabled(len(self.selected_paths) > 0) 

480 

481 def on_item_double_clicked(self, index: QModelIndex): 

482 """Handle double-click on items.""" 

483 file_path = Path(self.file_model.filePath(index)) 

484 

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

492 

493 def update_selection_list(self): 

494 """Update the selection list display.""" 

495 self.selection_list.clear() 

496 

497 for path in self.selected_paths: 

498 item = QListWidgetItem(path.name) 

499 item.setToolTip(str(path)) 

500 self.selection_list.addItem(item) 

501 

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 

514 

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 

519 

520 # Emit signal and call callback 

521 self.file_selected.emit([str(path) for path in self.selected_paths]) 

522 

523 if self.on_result_callback: 

524 self.on_result_callback(self.selected_paths) 

525 

526 self.accept() 

527 logger.debug(f"Selection confirmed: {[str(p) for p in self.selected_paths]}") 

528 

529 def cancel_selection(self): 

530 """Cancel the selection.""" 

531 self.selection_cancelled.emit() 

532 self.reject() 

533 logger.debug("Selection cancelled") 

534 

535 

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. 

546  

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 

555  

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