Coverage for openhcs/textual_tui/adapters/universal_directorytree.py: 0.0%

192 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 05:57 +0000

1""" 

2OpenHCS adapter for textual-universal-directorytree. 

3 

4This module provides a simplified adapter that creates OpenHCS-aware DirectoryTree 

5widgets by using standard UPath but with OpenHCS FileManager integration. 

6""" 

7 

8import logging 

9from pathlib import Path 

10from typing import Union, Iterable, Optional, List, Set 

11 

12from textual import events 

13from textual_universal_directorytree import UniversalDirectoryTree 

14from upath import UPath 

15 

16from openhcs.constants.constants import Backend 

17from openhcs.io.filemanager import FileManager 

18 

19logger = logging.getLogger(__name__) 

20class OpenHCSDirectoryTree(UniversalDirectoryTree): 

21 """ 

22 DirectoryTree widget that uses OpenHCS FileManager backend. 

23 

24 This is a simplified version that uses standard UPath but stores 

25 OpenHCS FileManager reference for potential future integration. 

26 For now, it works with local filesystem through UPath. 

27 

28 Custom click behavior: 

29 - Left click: Select single folder (no expansion, clears other selections) 

30 - Right click: Toggle folder in multi-selection 

31 - Double click: Navigate into folder (change view root) 

32 """ 

33 

34 def __init__( 

35 self, 

36 filemanager: FileManager, 

37 backend: Backend, 

38 path: Union[str, Path], 

39 show_hidden: bool = False, 

40 filter_extensions: Optional[List[str]] = None, 

41 enable_multi_selection: bool = False, 

42 **kwargs 

43 ): 

44 """ 

45 Initialize OpenHCS DirectoryTree. 

46 

47 Args: 

48 filemanager: OpenHCS FileManager instance 

49 backend: Backend to use (DISK, MEMORY, etc.) 

50 path: Initial path to display 

51 show_hidden: Whether to show hidden files (default: False) 

52 filter_extensions: Optional list of file extensions to show (e.g., ['.txt', '.py']) 

53 enable_multi_selection: Whether to enable multi-selection with right-click (default: False) 

54 **kwargs: Additional arguments passed to UniversalDirectoryTree 

55 """ 

56 self.filemanager = filemanager 

57 self.backend = backend 

58 self.show_hidden = show_hidden 

59 self.filter_extensions = filter_extensions 

60 self.enable_multi_selection = enable_multi_selection 

61 

62 # Track multi-selection state 

63 self.multi_selected_paths: Set[Path] = set() 

64 self.last_click_time = 0 

65 self.double_click_threshold = 0.25 # seconds 

66 

67 # For now, use standard UPath for local filesystem 

68 # TODO: Future enhancement could integrate FileManager more deeply 

69 if backend == Backend.DISK: 

70 upath = UPath(path) 

71 else: 

72 # For non-disk backends, fall back to local path for now 

73 # This could be enhanced to support other backends 

74 upath = UPath(path) 

75 logger.warning(f"Backend {backend.value} not fully supported yet, using local filesystem") 

76 

77 # Initialize parent with UPath 

78 super().__init__(path=upath, **kwargs) 

79 

80 logger.debug(f"OpenHCSDirectoryTree initialized with {backend.value} backend at {path}, show_hidden={show_hidden}") 

81 

82 def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]: 

83 """Filter paths to optionally hide hidden files and filter by extensions. 

84 

85 Args: 

86 paths: The paths to be filtered. 

87 

88 Returns: 

89 The filtered paths. 

90 """ 

91 filtered_paths = [] 

92 

93 for path in paths: 

94 # Filter hidden files 

95 if not self.show_hidden and path.name.startswith('.'): 

96 continue 

97 

98 # Filter by extension (only for files, not directories) 

99 if self.filter_extensions: 

100 try: 

101 # Use FileManager to check if it's a directory (respects backend abstraction) 

102 is_dir = self.filemanager.is_dir(path, self.backend.value) 

103 if not is_dir: # It's a file, apply extension filter 

104 if not any(path.name.lower().endswith(ext.lower()) for ext in self.filter_extensions): 

105 continue 

106 except Exception: 

107 # If we can't determine file type, skip extension filtering for this item 

108 # This preserves the item rather than breaking the entire operation 

109 pass 

110 

111 filtered_paths.append(path) 

112 

113 return filtered_paths 

114 

115 def on_click(self, event: events.Click) -> None: 

116 """Handle custom click behavior for folder selection.""" 

117 import time 

118 

119 # Always stop the event first to prevent tree expansion 

120 event.stop() 

121 event.prevent_default() 

122 

123 # Try to get the node at the click position 

124 try: 

125 # Adjust click coordinate for scroll offset 

126 # event.y is screen coordinate, but get_node_at_line expects content line number 

127 scroll_offset = self.scroll_offset 

128 adjusted_y = event.y + scroll_offset.y 

129 

130 logger.debug(f"Click at screen y={event.y}, scroll_offset={scroll_offset.y}, adjusted_y={adjusted_y}") 

131 

132 # Get the node at the adjusted click position 

133 clicked_node = self.get_node_at_line(adjusted_y) 

134 if not clicked_node: 

135 # Try using cursor_node as fallback 

136 clicked_node = getattr(self, 'cursor_node', None) 

137 if not clicked_node: 

138 logger.debug("Could not determine clicked node") 

139 return 

140 except Exception as e: 

141 logger.debug(f"Error getting clicked node: {e}") 

142 return 

143 

144 # Get the path from the node 

145 if not hasattr(clicked_node, 'data') or not clicked_node.data: 

146 logger.debug("Clicked node has no data") 

147 return 

148 

149 # Handle different data types (DirEntry, Path, str) 

150 if hasattr(clicked_node.data, 'path'): 

151 # It's a DirEntry object 

152 node_path = Path(clicked_node.data.path) 

153 else: 

154 # It's a Path or string 

155 node_path = Path(str(clicked_node.data)) 

156 current_time = time.time() 

157 

158 # Check if it's a double click 

159 is_double_click = (current_time - self.last_click_time) < self.double_click_threshold 

160 self.last_click_time = current_time 

161 

162 # Handle different click types 

163 if event.button == 3: # Right click 

164 self._handle_right_click(node_path) 

165 elif is_double_click: 

166 self._handle_double_click(node_path) 

167 else: # Left click 

168 # Set cursor to clicked node for visual feedback 

169 self.move_cursor(clicked_node) 

170 # Handle left click selection 

171 self._handle_left_click(node_path) 

172 

173 def _handle_left_click(self, path: Path) -> None: 

174 """Handle left click - select single folder without expansion.""" 

175 logger.info(f"🔍 LEFT CLICK: Selecting {path}") 

176 

177 # Clear multi-selection and select this path 

178 self.multi_selected_paths.clear() 

179 self.multi_selected_paths.add(path) 

180 

181 # Force complete UI update by invalidating and refreshing 

182 self._force_ui_update() 

183 

184 # Post directory selected event using the standard DirectoryTree message 

185 from textual.widgets import DirectoryTree 

186 self.post_message(DirectoryTree.DirectorySelected(self, path)) 

187 

188 def _handle_right_click(self, path: Path) -> None: 

189 """Handle right click - toggle folder in multi-selection if enabled, otherwise treat as left click.""" 

190 if not self.enable_multi_selection: 

191 # If multi-selection is disabled, treat right-click as left-click 

192 logger.info(f"🔍 RIGHT CLICK: Multi-selection disabled, treating as left click for {path}") 

193 self._handle_left_click(path) 

194 return 

195 

196 # Multi-selection is enabled - toggle selection 

197 if path in self.multi_selected_paths: 

198 logger.info(f"🔍 RIGHT CLICK: Removing {path} from multi-selection") 

199 self.multi_selected_paths.remove(path) 

200 else: 

201 logger.info(f"🔍 RIGHT CLICK: Adding {path} to multi-selection") 

202 self.multi_selected_paths.add(path) 

203 

204 # Force complete UI update by invalidating and refreshing 

205 self._force_ui_update() 

206 

207 # Also try to refresh the specific node that was clicked 

208 self._refresh_specific_path(path) 

209 

210 # Post directory selected event for the toggled path using the standard DirectoryTree message 

211 from textual.widgets import DirectoryTree 

212 self.post_message(DirectoryTree.DirectorySelected(self, path)) 

213 

214 def _handle_double_click(self, path: Path) -> None: 

215 """Handle double click - navigate into folder or select file.""" 

216 try: 

217 # Check if the path is a directory or file 

218 is_directory = self.filemanager.is_dir(path, self.backend.value) 

219 

220 if is_directory: 

221 # Navigate into the folder 

222 logger.info(f"🔍 DOUBLE CLICK: Navigating into directory {path}") 

223 self.post_message(self.NavigateToFolder(self, path)) 

224 else: 

225 # Select the file (equivalent to highlight + Select button) 

226 logger.info(f"🔍 DOUBLE CLICK: Selecting file {path}") 

227 self.post_message(self.SelectFile(self, path)) 

228 

229 except Exception as e: 

230 # Fallback: treat as directory navigation if we can't determine type 

231 logger.warning(f"🔍 DOUBLE CLICK: Could not determine type for {path}, treating as directory: {e}") 

232 self.post_message(self.NavigateToFolder(self, path)) 

233 

234 def _force_ui_update(self) -> None: 

235 """Force the tree UI to update by trying multiple aggressive refresh strategies.""" 

236 # Strategy 1: Clear internal caches that might prevent re-rendering 

237 try: 

238 # Clear line cache if it exists (common in Tree widgets) 

239 if hasattr(self, '_clear_line_cache'): 

240 self._clear_line_cache() 

241 # Clear any other caches 

242 if hasattr(self, '_clear_cache'): 

243 self._clear_cache() 

244 # Increment updates counter if it exists 

245 if hasattr(self, '_updates'): 

246 self._updates += 1 

247 except Exception: 

248 pass 

249 

250 # Strategy 2: Immediate comprehensive refresh 

251 self.refresh(layout=True, repaint=True) 

252 

253 # Strategy 3: Force complete re-render 

254 try: 

255 # Try to invalidate the widget's cache 

256 if hasattr(self, '_invalidate'): 

257 self._invalidate() 

258 elif hasattr(self, 'invalidate'): 

259 self.invalidate() 

260 except Exception: 

261 pass 

262 

263 # Strategy 4: Multiple refresh calls 

264 self.refresh() 

265 self.refresh(layout=True) 

266 self.refresh(repaint=True) 

267 

268 # Strategy 5: Force parent container refresh 

269 if self.parent: 

270 self.parent.refresh(layout=True, repaint=True) 

271 

272 # Strategy 6: Refresh all visible lines using Tree-specific methods 

273 try: 

274 # Get the number of visible lines and refresh them all 

275 if hasattr(self, 'last_line') and self.last_line >= 0: 

276 self.refresh_lines(0, self.last_line + 1) 

277 except Exception: 

278 pass 

279 

280 # Strategy 7: Schedule immediate and delayed refreshes 

281 self.call_next(lambda: self.refresh(layout=True, repaint=True)) 

282 self.set_timer(0.001, lambda: self.refresh(layout=True, repaint=True)) 

283 self.set_timer(0.01, lambda: self.refresh(layout=True, repaint=True)) 

284 

285 # Strategy 8: Force app-level refresh if available 

286 if hasattr(self, 'app') and self.app: 

287 self.app.refresh() 

288 

289 def _refresh_specific_path(self, path: Path) -> None: 

290 """Try to refresh the specific tree node for the given path.""" 

291 try: 

292 # Try to find the node for this path and refresh it specifically 

293 # This is more targeted than refreshing the entire tree 

294 

295 # Method 1: Try to find the node by walking the tree 

296 def find_node_for_path(node, target_path): 

297 if hasattr(node, 'data') and node.data: 

298 # Handle different data types (DirEntry, Path, str) 

299 if hasattr(node.data, 'path'): 

300 node_path = Path(node.data.path) 

301 else: 

302 node_path = Path(str(node.data)) 

303 

304 if node_path == target_path: 

305 return node 

306 

307 # Recursively check children 

308 for child in getattr(node, 'children', []): 

309 result = find_node_for_path(child, target_path) 

310 if result: 

311 return result 

312 return None 

313 

314 # Find the node for this path 

315 if hasattr(self, 'root') and self.root: 

316 target_node = find_node_for_path(self.root, path) 

317 if target_node: 

318 # Refresh this specific node 

319 if hasattr(self, '_refresh_node'): 

320 self._refresh_node(target_node) 

321 # Also refresh its line if we can find it 

322 if hasattr(target_node, 'line') and target_node.line >= 0: 

323 if hasattr(self, 'refresh_line'): 

324 self.refresh_line(target_node.line) 

325 if hasattr(self, '_refresh_line'): 

326 self._refresh_line(target_node.line) 

327 except Exception: 

328 # If targeted refresh fails, fall back to general refresh 

329 pass 

330 

331 def render_label(self, node, base_style, style): 

332 """Override label rendering to show multi-selection state.""" 

333 # Get the default rendered label from parent 

334 label = super().render_label(node, base_style, style) 

335 

336 # Check if this node's path is in multi-selection 

337 if hasattr(node, 'data') and node.data: 

338 # Handle different data types (DirEntry, Path, str) 

339 if hasattr(node.data, 'path'): 

340 # It's a DirEntry object 

341 node_path = Path(node.data.path) 

342 else: 

343 # It's a Path or string 

344 node_path = Path(str(node.data)) 

345 

346 if node_path in self.multi_selected_paths: 

347 # Add visual indicator for selected items 

348 from rich.text import Text 

349 # Create a new label with selection styling 

350 selected_label = Text() 

351 selected_label.append("✓ ", style="bold green") # Checkmark prefix 

352 selected_label.append(label) 

353 selected_label.stylize("bold") # Make the whole label bold 

354 return selected_label 

355 

356 return label 

357 

358 class AddToSelectionList(events.Message): 

359 """Message posted when folders should be added to selection list.""" 

360 

361 def __init__(self, sender, paths: List[Path]) -> None: 

362 super().__init__() 

363 self.sender = sender 

364 self.paths = paths 

365 

366 class NavigateToFolder(events.Message): 

367 """Message posted when user double-clicks to navigate into a folder.""" 

368 

369 def __init__(self, sender, path: Path) -> None: 

370 super().__init__() 

371 self.sender = sender 

372 self.path = path 

373 

374 class SelectFile(events.Message): 

375 """Message posted when user double-clicks a file to select it.""" 

376 

377 def __init__(self, sender, path: Path) -> None: 

378 super().__init__() 

379 self.sender = sender 

380 self.path = path