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
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
1"""
2OpenHCS adapter for textual-universal-directorytree.
4This module provides a simplified adapter that creates OpenHCS-aware DirectoryTree
5widgets by using standard UPath but with OpenHCS FileManager integration.
6"""
8import logging
9from pathlib import Path
10from typing import Union, Iterable, Optional, List, Set
12from textual import events
13from textual_universal_directorytree import UniversalDirectoryTree
14from upath import UPath
16from openhcs.constants.constants import Backend
17from openhcs.io.filemanager import FileManager
19logger = logging.getLogger(__name__)
20class OpenHCSDirectoryTree(UniversalDirectoryTree):
21 """
22 DirectoryTree widget that uses OpenHCS FileManager backend.
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.
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 """
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.
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
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
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")
77 # Initialize parent with UPath
78 super().__init__(path=upath, **kwargs)
80 logger.debug(f"OpenHCSDirectoryTree initialized with {backend.value} backend at {path}, show_hidden={show_hidden}")
82 def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
83 """Filter paths to optionally hide hidden files and filter by extensions.
85 Args:
86 paths: The paths to be filtered.
88 Returns:
89 The filtered paths.
90 """
91 filtered_paths = []
93 for path in paths:
94 # Filter hidden files
95 if not self.show_hidden and path.name.startswith('.'):
96 continue
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
111 filtered_paths.append(path)
113 return filtered_paths
115 def on_click(self, event: events.Click) -> None:
116 """Handle custom click behavior for folder selection."""
117 import time
119 # Always stop the event first to prevent tree expansion
120 event.stop()
121 event.prevent_default()
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
130 logger.debug(f"Click at screen y={event.y}, scroll_offset={scroll_offset.y}, adjusted_y={adjusted_y}")
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
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
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()
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
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)
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}")
177 # Clear multi-selection and select this path
178 self.multi_selected_paths.clear()
179 self.multi_selected_paths.add(path)
181 # Force complete UI update by invalidating and refreshing
182 self._force_ui_update()
184 # Post directory selected event using the standard DirectoryTree message
185 from textual.widgets import DirectoryTree
186 self.post_message(DirectoryTree.DirectorySelected(self, path))
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
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)
204 # Force complete UI update by invalidating and refreshing
205 self._force_ui_update()
207 # Also try to refresh the specific node that was clicked
208 self._refresh_specific_path(path)
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))
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)
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))
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))
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
250 # Strategy 2: Immediate comprehensive refresh
251 self.refresh(layout=True, repaint=True)
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
263 # Strategy 4: Multiple refresh calls
264 self.refresh()
265 self.refresh(layout=True)
266 self.refresh(repaint=True)
268 # Strategy 5: Force parent container refresh
269 if self.parent:
270 self.parent.refresh(layout=True, repaint=True)
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
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))
285 # Strategy 8: Force app-level refresh if available
286 if hasattr(self, 'app') and self.app:
287 self.app.refresh()
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
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))
304 if node_path == target_path:
305 return node
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
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
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)
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))
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
356 return label
358 class AddToSelectionList(events.Message):
359 """Message posted when folders should be added to selection list."""
361 def __init__(self, sender, paths: List[Path]) -> None:
362 super().__init__()
363 self.sender = sender
364 self.paths = paths
366 class NavigateToFolder(events.Message):
367 """Message posted when user double-clicks to navigate into a folder."""
369 def __init__(self, sender, path: Path) -> None:
370 super().__init__()
371 self.sender = sender
372 self.path = path
374 class SelectFile(events.Message):
375 """Message posted when user double-clicks a file to select it."""
377 def __init__(self, sender, path: Path) -> None:
378 super().__init__()
379 self.sender = sender
380 self.path = path