diff --git a/src/textual_fspicker/__init__.py b/src/textual_fspicker/__init__.py index 77ef431..18c4bfc 100644 --- a/src/textual_fspicker/__init__.py +++ b/src/textual_fspicker/__init__.py @@ -2,7 +2,7 @@ ############################################################################## # Python imports. -from importlib.metadata import version +# from importlib.metadata import version ###################################################################### # Main app information. diff --git a/src/textual_fspicker/base_dialog.py b/src/textual_fspicker/base_dialog.py index 80d0e77..f72905b 100644 --- a/src/textual_fspicker/base_dialog.py +++ b/src/textual_fspicker/base_dialog.py @@ -8,16 +8,19 @@ # Python imports. import sys from pathlib import Path -from typing import Callable, TypeAlias +from typing import Callable, TypeAlias, List, Dict, Any +import json +from datetime import datetime ############################################################################## # Textual imports. from textual import on from textual.app import ComposeResult from textual.binding import Binding -from textual.containers import Horizontal, Vertical +from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import ModalScreen -from textual.widgets import Button +from textual.widgets import Button, Label, Input, ListView, ListItem +from textual.reactive import reactive ############################################################################## # Local imports. @@ -63,6 +66,60 @@ class FileSystemPickerScreen(ModalScreen[Path | None]): } } + #current_path_display { + width: 1fr; + padding: 0 1; + margin-bottom: 1; + overflow: hidden; + text-overflow: ellipsis; + color: $text-muted; + } + + #path-breadcrumbs { + height: 3; + padding: 1; + background: $surface; + margin-bottom: 1; + } + + #path-breadcrumbs Button { + min-width: 0; + padding: 0 1; + margin: 0; + height: 1; + background: transparent; + border: none; + } + + #path-breadcrumbs Button:hover { + background: $boost; + } + + #clear-search { + background: $boost; + border: none; + } + + #path-breadcrumbs .breadcrumb-separator { + margin: 0 1; + color: $text-muted; + } + + #search-container { + height: 3; + padding: 0 1; + margin-bottom: 1; + display: none; + } + + #search-container.visible { + display: block; + } + + #search-input { + width: 1fr; + } + DirectoryNavigation { height: 1fr; } @@ -83,9 +140,19 @@ class FileSystemPickerScreen(ModalScreen[Path | None]): ERROR_PERMISSION_ERROR = "Permission error" """Error to tell there user there was a problem with permissions.""" - BINDINGS = [Binding("full_stop", "hidden"), Binding("escape", "dismiss(None)")] + BINDINGS = [ + Binding("full_stop", "hidden", "Toggle hidden"), + Binding("ctrl+h", "hidden", "Toggle hidden files"), + Binding("ctrl+l", "focus_path_input", "Edit path directly"), + Binding("f5", "refresh", "Refresh directory"), + Binding("ctrl+f", "focus_search", "Search in directory"), + Binding("escape", "dismiss(None)", "Cancel") + ] """The bindings for the dialog.""" - + + search_active = reactive(False) + """Whether search is active.""" + def __init__( self, location: str | Path = ".", @@ -138,18 +205,46 @@ def compose(self) -> ComposeResult: """ with Dialog() as dialog: dialog.border_title = self._title + + # Path display and breadcrumbs + yield Label(id="current_path_display") + with Horizontal(id="path-breadcrumbs"): + # Breadcrumbs will be dynamically populated + pass + + # Path input field (hidden by default, shown with Ctrl+L) + with Horizontal(id="path-input-container", classes="hidden"): + yield Input(placeholder="Enter path...", id="path-input") + yield Button("Go", id="go-to-path", variant="primary") + yield Button("Cancel", id="cancel-path-input", variant="default") + + # Search container (hidden by default) + with Horizontal(id="search-container"): + yield Input(placeholder="Search files...", id="search-input") + yield Button("Clear", id="clear-search", variant="default") + + # Main directory navigation with Horizontal(): if sys.platform == "win32": yield DriveNavigation(self._location) yield DirectoryNavigation(self._location) + + # Input bar with buttons with InputBar(): yield from self._input_bar() yield Button(self._label(self._select_button, "Select"), id="select") yield Button(self._label(self._cancel_button, "Cancel"), id="cancel") def on_mount(self) -> None: - """Focus directory widget on mount.""" - self.query_one(DirectoryNavigation).focus() + """Focus directory widget on mount and set initial path.""" + dir_nav = self.query_one(DirectoryNavigation) + current_path_label = self.query_one("#current_path_display", Label) + current_path_label.update(str(dir_nav.location)) + + # Initialize breadcrumbs + self._update_breadcrumbs(dir_nav.location) + + dir_nav.focus() def _set_error(self, message: str = "") -> None: """Set or clear the error message. @@ -162,7 +257,20 @@ def _set_error(self, message: str = "") -> None: @on(DriveNavigation.DriveSelected) def _change_drive(self, event: DriveNavigation.DriveSelected) -> None: """Reload DirectoryNavigation in response to drive change.""" - self.query_one(DirectoryNavigation).location = event.drive_root + """Reload DirectoryNavigation in response to drive change.""" + dir_nav = self.query_one(DirectoryNavigation) + dir_nav.location = event.drive_root + + @on(DirectoryNavigation.Changed) + def _on_directory_changed(self, event: DirectoryNavigation.Changed) -> None: + """Clear any error and update the path display.""" + self._set_error() + current_path_label = self.query_one("#current_path_display", Label) + current_path_label.update(str(event.control.location)) + + # Update breadcrumbs + self._update_breadcrumbs(event.control.location) + @on(DirectoryNavigation.Changed) def _clear_error(self) -> None: @@ -187,6 +295,164 @@ def _cancel(self, event: Button.Pressed) -> None: def _action_hidden(self) -> None: """Action for toggling the display of hidden entries.""" self.query_one(DirectoryNavigation).toggle_hidden() + self.notify("Hidden files toggled", timeout=2) + + def action_focus_path_input(self) -> None: + """Toggle and focus the path input field.""" + try: + path_container = self.query_one("#path-input-container") + path_input = self.query_one("#path-input", Input) + + # Toggle visibility + if path_container.has_class("hidden"): + path_container.remove_class("hidden") + # Set current path as the initial value + dir_nav = self.query_one(DirectoryNavigation) + path_input.value = str(dir_nav.location) + path_input.focus() + # Select all text in the input + path_input.selection = (0, len(path_input.value)) + else: + path_container.add_class("hidden") + # Return focus to directory navigation + self.query_one(DirectoryNavigation).focus() + except Exception as e: + self.notify(f"Error toggling path input: {e}", severity="error", timeout=2) + + def action_refresh(self) -> None: + """Refresh the current directory listing.""" + dir_nav = self.query_one(DirectoryNavigation) + # Force refresh by resetting location + current = dir_nav.location + dir_nav.location = current + self.notify("Directory refreshed", timeout=2) + + def action_focus_search(self) -> None: + """Toggle search mode and focus search input.""" + self.search_active = not self.search_active + if self.search_active: + try: + search_input = self.query_one("#search-input", Input) + search_input.focus() + except Exception: + pass + + def _update_breadcrumbs(self, path: Path) -> None: + """Update breadcrumb navigation.""" + try: + breadcrumb_container = self.query_one("#path-breadcrumbs", Horizontal) + breadcrumb_container.remove_children() + + parts = path.parts + for i, part in enumerate(parts): + partial_path = Path(*parts[:i+1]) + + # Create button for each path component + btn = Button(part, variant="default", classes="breadcrumb-btn") + btn.tooltip = str(partial_path) # Store full path in tooltip + breadcrumb_container.mount(btn) + + # Add separator if not last + if i < len(parts) - 1: + breadcrumb_container.mount(Label("/", classes="breadcrumb-separator")) + except Exception as e: + # Silently fail if breadcrumbs can't be updated + pass + + @on(Button.Pressed, ".breadcrumb-btn") + def _on_breadcrumb_click(self, event: Button.Pressed) -> None: + """Handle breadcrumb navigation clicks.""" + if event.button.tooltip: + try: + path = Path(event.button.tooltip) + dir_nav = self.query_one(DirectoryNavigation) + dir_nav.location = path + except Exception: + pass + + @on(Input.Changed, "#search-input") + def _on_search_changed(self, event: Input.Changed) -> None: + """Handle search input changes.""" + try: + dir_nav = self.query_one(DirectoryNavigation) + dir_nav.search_filter = event.value + except Exception: + pass + + @on(Button.Pressed, "#clear-search") + def _on_clear_search(self) -> None: + """Clear the search input.""" + try: + search_input = self.query_one("#search-input", Input) + search_input.value = "" + self.search_active = False + except Exception: + pass + + def watch_search_active(self, active: bool) -> None: + """React to search_active changes.""" + try: + search_container = self.query_one("#search-container") + search_container.set_class(active, "visible") + except Exception: + pass + + @on(Button.Pressed, "#go-to-path") + @on(Input.Submitted, "#path-input") + def _on_path_input_submit(self, event=None) -> None: + """Handle path input submission.""" + try: + path_input = self.query_one("#path-input", Input) + path_str = path_input.value.strip() + + if not path_str: + return + + # Expand user home directory if needed + if path_str.startswith("~"): + path = Path(path_str).expanduser() + else: + path = Path(path_str) + + # Make path absolute if it's relative + if not path.is_absolute(): + dir_nav = self.query_one(DirectoryNavigation) + path = dir_nav.location / path + + # Resolve the path + path = path.resolve() + + # Check if path exists + if not path.exists(): + self.notify(f"Path does not exist: {path}", severity="error", timeout=3) + return + + # Navigate to the path + dir_nav = self.query_one(DirectoryNavigation) + if path.is_dir(): + dir_nav.location = path + else: + # If it's a file, navigate to its parent directory + dir_nav.location = path.parent + # TODO: Ideally, we would also select the file in the list + + # Hide the path input + path_container = self.query_one("#path-input-container") + path_container.add_class("hidden") + dir_nav.focus() + + except Exception as e: + self.notify(f"Error navigating to path: {e}", severity="error", timeout=3) + + @on(Button.Pressed, "#cancel-path-input") + def _on_cancel_path_input(self) -> None: + """Cancel path input and hide the container.""" + try: + path_container = self.query_one("#path-input-container") + path_container.add_class("hidden") + self.query_one(DirectoryNavigation).focus() + except Exception: + pass ### base_dialog.py ends here diff --git a/src/textual_fspicker/file_dialog.py b/src/textual_fspicker/file_dialog.py index 2a3244d..c4d08d0 100644 --- a/src/textual_fspicker/file_dialog.py +++ b/src/textual_fspicker/file_dialog.py @@ -182,7 +182,7 @@ def _confirm_file(self, event: Input.Submitted | Button.Pressed) -> None: try: if chosen.is_dir(): if sys.platform == "win32": - if drive := MakePath.of(file_name.value).drive: + if drive := MakePath.of(file_name.value).drive: self.query_one(DriveNavigation).drive = drive self.query_one(DirectoryNavigation).location = chosen self.query_one(DirectoryNavigation).focus() diff --git a/src/textual_fspicker/parts/directory_navigation.py b/src/textual_fspicker/parts/directory_navigation.py index 2b9b422..0fe317b 100644 --- a/src/textual_fspicker/parts/directory_navigation.py +++ b/src/textual_fspicker/parts/directory_navigation.py @@ -441,6 +441,10 @@ def _watch_sort_display(self) -> None: def _watch_file_filter(self) -> None: """Refresh the display when the file filter has been changed.""" self._repopulate_display() + + def _watch_search_filter(self) -> None: + """Refresh the display when the search filter has been changed.""" + self._repopulate_display() def toggle_hidden(self) -> None: """Toggle the display of hidden filesystem entries.""" diff --git a/src/textual_fspicker/py.typed b/src/textual_fspicker/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/src/textual_fspicker/select_directory.py b/src/textual_fspicker/select_directory.py index 5fc2320..009a3af 100644 --- a/src/textual_fspicker/select_directory.py +++ b/src/textual_fspicker/select_directory.py @@ -13,57 +13,27 @@ from textual import on from textual.app import ComposeResult from textual.reactive import var -from textual.widgets import Button, Label +from textual.widgets import Button, Label, Input ############################################################################## # Local imports. from .base_dialog import ButtonLabel, FileSystemPickerScreen from .parts import DirectoryNavigation - +from .path_maker import MakePath ############################################################################## -class CurrentDirectory(Label): - """A widget to show the current directory. - - This widget is used inside a - [`SelectDirectory`][textual_fspicker.SelectDirectory] dialog to display - the currently-selected directory. - """ +class SelectDirectory(FileSystemPickerScreen): + """A directory selection dialog.""" - DEFAULT_CSS = """ - CurrentDirectory { - width: 1fr; - height: 3; - border: tall $background; - padding-left: 1; - padding-right: 1; + DEFAULT_CSS = FileSystemPickerScreen.DEFAULT_CSS + """ + SelectDirectory InputBar { + Input { /* Style for the new path input */ + width: 2fr; /* Give it more space than buttons */ + margin-right: 1; + } } """ - current_directory: var[Path | None] = var(None, always_update=True) - """The current directory.""" - - def _watch_current_directory(self) -> None: - """Watch for the current directory being changed.""" - if ( - len( - display := "" - if self.current_directory is None - else str(self.current_directory)[-self.size.width :] - ) - >= self.size.width - ): - display = f"…{display[1:]}" - self.update(display) - - def _on_resize(self) -> None: - self.current_directory = self.current_directory - - -############################################################################## -class SelectDirectory(FileSystemPickerScreen): - """A directory selection dialog.""" - def __init__( self, location: str | Path = ".", @@ -91,23 +61,54 @@ def __init__( def on_mount(self) -> None: """Configure the dialog once the DOM is ready.""" + super().on_mount() # Call parent's on_mount navigation = self.query_one(DirectoryNavigation) navigation.show_files = False - self.query_one(CurrentDirectory).current_directory = navigation.location + + path_input = self.query_one("#path_input", Input) + path_input.value = str(navigation.location) + # navigation.focus() # Focus is handled by super().on_mount or should be reconsidered def _input_bar(self) -> ComposeResult: """Provide any widgets for the input before, before the buttons.""" - yield CurrentDirectory() + yield Input(id="path_input", placeholder="Type path or select below") @on(DirectoryNavigation.Changed) - def _show_selected(self, event: DirectoryNavigation.Changed) -> None: - """Update the display of the current location. + def _update_path_input_on_nav_change(self, event: DirectoryNavigation.Changed) -> None: + """Update the display of the current location in the Input widget. Args: event: The event with the selection information in. """ + super()._on_directory_changed(event) # Call parent handler for main path label and error clearing + event.stop() # Stop event propagation if necessary, depending on desired interactions + path_input = self.query_one("#path_input", Input) + path_input.value = str(event.control.location) + + @on(Input.Submitted, "#path_input") + def _handle_path_input_submission(self, event: Input.Submitted) -> None: + """Handle submission of the path Input widget.""" event.stop() - self.query_one(CurrentDirectory).current_directory = event.control.location + path_value = event.value + dir_nav = self.query_one(DirectoryNavigation) + try: + # Attempt to resolve the path + target_path = MakePath.of(path_value).expanduser().resolve() + if target_path.is_dir(): + dir_nav.location = target_path # This will trigger DirectoryNavigation.Changed + else: + self._set_error(f"Not a directory: {target_path.name}") + self.query_one("#path_input", Input).focus() + except FileNotFoundError: + self._set_error(f"Path not found: {path_value}") + self.query_one("#path_input", Input).focus() + except PermissionError: + self._set_error(self.ERROR_PERMISSION_ERROR) + self.query_one("#path_input", Input).focus() + except RuntimeError as e: # For MakePath.expanduser() issues + self._set_error(str(e)) + self.query_one("#path_input", Input).focus() + @on(Button.Pressed, "#select") def _select_directory(self, event: Button.Pressed) -> None: @@ -117,6 +118,8 @@ def _select_directory(self, event: Button.Pressed) -> None: event: The button press event. """ event.stop() + # The location in DirectoryNavigation is the source of truth, + # whether set by list interaction or by the path_input. self.dismiss(result=self.query_one(DirectoryNavigation).location)