Skip to content

Search #49

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 12 commits into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/textual_fspicker/__init__.py
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@

##############################################################################
# Python imports.
from importlib.metadata import version
# from importlib.metadata import version

######################################################################
# Main app information.
282 changes: 274 additions & 8 deletions src/textual_fspicker/base_dialog.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/textual_fspicker/file_dialog.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 4 additions & 0 deletions src/textual_fspicker/parts/directory_navigation.py
Original file line number Diff line number Diff line change
@@ -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."""
Empty file removed src/textual_fspicker/py.typed
Empty file.
93 changes: 48 additions & 45 deletions src/textual_fspicker/select_directory.py
Original file line number Diff line number Diff line change
@@ -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)