"""
Directory browser modal widget.
This module provides the DirectoryBrowserScreen modal for selecting
directories with customizable validation.
**Features:**
- Textual DirectoryTree widget for file system navigation
- Vim-style keybindings (h/j/k/l) for navigation
- Customizable validation callback
- Visual feedback for valid/invalid selections
- Select/Cancel buttons with state management
**Example Usage:**
.. code-block:: python
from pathlib import Path
from build_tools.tui_common.controls import DirectoryBrowserScreen
# Custom validator for source directories
def validate_source_dir(path: Path) -> tuple[bool, str, str]:
txt_files = list(path.glob("*.txt"))
if txt_files:
return (True, "source", f"Found {len(txt_files)} text files")
return (False, "", "No .txt files found")
# In your App
async def select_directory(self) -> None:
result = await self.push_screen_wait(
DirectoryBrowserScreen(
title="Select Source Directory",
validator=validate_source_dir,
initial_dir=Path.cwd(),
)
)
if result:
print(f"Selected: {result}")
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
from textual import on
from textual.containers import Container, Horizontal
from textual.css.query import NoMatches
from textual.screen import ModalScreen
from textual.widgets import Button, DirectoryTree, Label, Static, Tree
if TYPE_CHECKING:
from collections.abc import Iterable
from textual.app import ComposeResult
# Type alias for validator function signature
# Returns: (is_valid, type_label, message)
# - is_valid: True if directory is valid for selection
# - type_label: Short label describing what was found (e.g., "corpus", "source")
# - message: Human-readable message (error if invalid, description if valid)
DirectoryValidator = Callable[[Path], tuple[bool, str, str]]
[docs]
class FilterableDirectoryTree(DirectoryTree):
"""
DirectoryTree subclass that supports filtering hidden files.
By default, hidden files (those starting with '.') are not shown.
Toggle visibility with the ``show_hidden`` attribute.
"""
def __init__(self, path: str, show_hidden: bool = False, **kwargs: Any) -> None:
"""
Initialize filterable directory tree.
Args:
path: Root path for the tree
show_hidden: If True, show hidden files/directories
**kwargs: Additional arguments passed to DirectoryTree
"""
super().__init__(path, **kwargs)
self._show_hidden = show_hidden
@property
def show_hidden(self) -> bool:
"""Whether hidden files are shown."""
return self._show_hidden
@show_hidden.setter
def show_hidden(self, value: bool) -> None:
"""Set hidden file visibility and reload tree."""
if self._show_hidden != value:
self._show_hidden = value
self.reload()
[docs]
def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
"""
Filter paths to optionally exclude hidden files.
Args:
paths: Iterable of paths to filter
Returns:
Filtered iterable of paths
"""
if self._show_hidden:
return paths
return [p for p in paths if not p.name.startswith(".")]
[docs]
def default_validator(path: Path) -> tuple[bool, str, str]:
"""
Default validator that accepts any directory.
Args:
path: Directory path to validate
Returns:
Tuple of (True, "directory", path name) for any directory
"""
return (True, "directory", path.name)
[docs]
class DirectoryBrowserScreen(ModalScreen[Path | None]):
"""
Modal screen for browsing and selecting a directory.
A modal dialog displaying a directory tree for navigation with
customizable validation. Returns the selected directory path
when user confirms, or None if cancelled.
**Validation:**
Provide a ``validator`` callable that takes a Path and returns
a tuple of ``(is_valid, type_label, message)``:
- ``is_valid``: True if the directory can be selected
- ``type_label``: Short label for valid directories (e.g., "corpus")
- ``message``: Error message if invalid, or description if valid
**Navigation:**
- ``j`` / ``down``: Move cursor down
- ``k`` / ``up``: Move cursor up
- ``h`` / ``left``: Collapse directory
- ``l`` / ``right``: Expand directory
- ``>`` (Shift+.): Toggle hidden files
Attributes:
browser_title: Header text displayed at top of modal
validator: Callback function to validate selected directories
initial_dir: Starting directory for the browser
selected_path: Currently selected path (or None)
Returns:
Selected Path when "Select" is pressed, or None if cancelled
Example:
.. code-block:: python
result = await self.app.push_screen_wait(
DirectoryBrowserScreen(
title="Select Corpus Directory",
validator=validate_corpus_directory,
initial_dir=Path.home() / "corpora",
)
)
if result:
self.load_corpus(result)
"""
# -------------------------------------------------------------------------
# Vim-style navigation bindings
# -------------------------------------------------------------------------
BINDINGS = [
("j", "cursor_down", "Down"),
("k", "cursor_up", "Up"),
("h", "cursor_left", "Collapse"),
("l", "cursor_right", "Expand"),
("space", "toggle_node", "Toggle"),
("enter", "select_node", "Select"),
("escape", "cancel", "Cancel"),
("greater_than_sign", "toggle_hidden", "Toggle hidden"),
]
# -------------------------------------------------------------------------
# Modal styling
# -------------------------------------------------------------------------
CSS = """
DirectoryBrowserScreen {
align: center middle;
}
#browser-container {
width: 80;
height: 30;
background: $panel;
border: thick $primary;
padding: 1;
}
#browser-header {
text-align: center;
text-style: bold;
color: $accent;
margin-bottom: 1;
}
#directory-tree {
width: 100%;
height: 1fr;
border: solid $primary;
margin-bottom: 1;
}
#help-text {
height: 2;
width: 100%;
color: $text-muted;
text-align: center;
margin-bottom: 1;
}
#validation-status {
height: 3;
width: 100%;
border: solid $primary;
padding: 0 1;
margin-bottom: 1;
}
.status-valid {
color: $success;
}
.status-invalid {
color: $error;
}
.status-none {
color: $text-muted;
}
#button-bar {
width: 100%;
height: auto;
align: center middle;
}
#button-bar Button {
margin: 0 1;
}
"""
[docs]
def __init__(
self,
title: str = "Select Directory",
validator: DirectoryValidator | None = None,
initial_dir: Path | None = None,
help_text: str | None = None,
root_dir: Path | None = None,
) -> None:
"""
Initialize directory browser.
Args:
title: Header text displayed at top of modal
validator: Callback function to validate directories.
Signature: ``(Path) -> (is_valid, type_label, message)``
If None, uses default_validator which accepts any directory.
initial_dir: Starting directory for browser (defaults to home directory)
help_text: Custom help text to display. If None, uses default help text.
root_dir: Root directory for the tree. If None, uses home directory.
Set this higher than initial_dir to allow navigating up.
"""
super().__init__()
self.browser_title = title
self.validator = validator or default_validator
self.initial_dir = initial_dir or Path.home()
self.root_dir = root_dir or Path.home()
self.help_text = help_text or "Expand a directory to validate it. Click Select when valid."
self.selected_path: Path | None = None
self.show_hidden = False
[docs]
def compose(self) -> ComposeResult:
"""
Create browser UI layout.
Layout:
- Header with title
- Help text
- Directory tree (expandable/collapsible)
- Validation status display
- Select/Cancel buttons
Yields:
Composed widget tree for the modal
"""
with Container(id="browser-container"):
yield Label(self.browser_title, id="browser-header")
# Help text for navigation
yield Label(self.help_text or "", id="help-text")
# Directory tree widget - use root_dir to allow navigating up
yield FilterableDirectoryTree(
str(self.root_dir), show_hidden=self.show_hidden, id="directory-tree"
)
# Validation status area
with Static(id="validation-status", classes="status-none"):
yield Label("Select a directory to validate", id="status-text")
# Action buttons
with Horizontal(id="button-bar"):
yield Button(
"Select",
variant="primary",
id="select-button",
disabled=True,
)
yield Button("Cancel", variant="default", id="cancel-button")
[docs]
async def on_mount(self) -> None:
"""
Handle screen mount event.
If initial_dir differs from root_dir, attempt to expand the tree
to show and select initial_dir after a brief delay to let the
tree load its initial content.
"""
if self.initial_dir != self.root_dir:
# Schedule expansion after tree has loaded
self.set_timer(0.1, self._expand_to_initial_dir)
def _expand_to_initial_dir(self) -> None:
"""
Expand tree nodes from root to initial_dir.
This navigates the tree by expanding each directory in the path
from root_dir to initial_dir, allowing the user to see their
starting location while being able to navigate up.
"""
tree = self.query_one("#directory-tree", DirectoryTree)
# Get the relative path from root to initial_dir
try:
relative_parts = self.initial_dir.relative_to(self.root_dir).parts
except ValueError:
# initial_dir is not under root_dir, can't expand
return
# Walk the tree expanding each node in the path
current_node = tree.root
current_path = self.root_dir
# Expand root first
if not current_node.is_expanded:
current_node.expand()
# Function to find and expand child nodes
def find_and_expand_child(node: Any, target_name: str) -> Any | None:
"""Find a child node by name and expand it."""
for child in node.children:
if child.data and hasattr(child.data, "path"):
child_path = Path(child.data.path)
if child_path.name == target_name:
if not child.is_expanded:
child.expand()
return child
return None
# Expand each part of the path
for part in relative_parts:
current_path = current_path / part
child_node = find_and_expand_child(current_node, part)
if child_node:
current_node = child_node
else:
# Could not find node, tree might not be loaded yet
break
# Move cursor to the final node if we got there
# Note: We only move the cursor, we don't validate. Validation should
# happen when the user explicitly selects a directory (via click or Enter).
if current_node and current_node != tree.root:
tree.select_node(current_node)
def _validate_and_update_status(self, path: Path) -> None:
"""
Validate a directory and update the UI status accordingly.
This is the core validation logic used by both DirectorySelected
and NodeExpanded handlers to provide consistent behavior.
Args:
path: Directory path to validate
"""
self.selected_path = path
# Validate directory using configured validator
is_valid, type_label, message = self.validator(path)
# Update status display. During teardown/racey UI transitions (seen on
# slower CI runners), delayed tree events can fire after the modal has
# started unmounting. In that case, UI nodes are gone and we should
# safely ignore the late validation event.
widgets = self._get_validation_widgets()
if widgets is None:
return
status_container, status_text, select_button = widgets
if is_valid:
# Valid selection - enable button, show success message
status_container.remove_class("status-invalid", "status-none")
status_container.add_class("status-valid")
status_text.update(f"[checkmark] Valid {type_label}\n{path.name}")
select_button.disabled = False
else:
# Invalid selection - disable button, show error
status_container.remove_class("status-valid", "status-none")
status_container.add_class("status-invalid")
status_text.update(f"[x] Invalid selection\n{message}")
select_button.disabled = True
[docs]
@on(DirectoryTree.DirectorySelected)
def directory_selected(self, event: DirectoryTree.DirectorySelected) -> None:
"""
Handle directory selection in tree.
Validates the selected directory using the configured validator
and updates the UI accordingly. Triggered when user clicks on
a directory NAME (not the expand arrow).
Args:
event: Directory selection event from DirectoryTree
"""
self._validate_and_update_status(Path(event.path))
[docs]
@on(Tree.NodeExpanded)
def node_expanded(self, event: Tree.NodeExpanded) -> None:
"""
Handle directory expansion in tree.
When a user expands a directory (via arrow click or 'l' key),
validate it and allow selection if valid. This improves UX by
letting users select a directory after navigating into it,
without needing to click on the directory name again.
Note: We use Tree.NodeExpanded because DirectoryTree inherits from
Tree and doesn't define its own NodeExpanded message class.
Args:
event: Node expanded event from Tree (parent class of DirectoryTree)
"""
# NodeExpanded provides the tree node, get the path from it
node = event.node
if node.data and hasattr(node.data, "path"):
# DirectoryTree nodes have DirEntry data with a path attribute
path = Path(node.data.path)
if path.is_dir():
self._validate_and_update_status(path)
[docs]
@on(DirectoryTree.FileSelected)
def file_selected(self, event: DirectoryTree.FileSelected) -> None:
"""
Handle file selection in tree.
When a file is clicked, we don't clear the current selection since
the user may have already validated the parent directory by expanding
into it. Instead, we provide a gentle reminder that the parent
directory is what will be selected.
Args:
event: File selection event from DirectoryTree
"""
file_path = Path(event.path)
parent_dir = file_path.parent
# If the parent directory is currently selected and valid, just remind
# the user that they can click Select
if self.selected_path == parent_dir:
# Don't change validation state - parent is still selected
widgets = self._get_validation_widgets()
if widgets is None:
return
status_container, status_text, _ = widgets
# Keep the valid status but update the message
if status_container.has_class("status-valid"):
status_text.update(
f"[checkmark] Directory ready to select\n"
f"Click Select to use: {parent_dir.name}"
)
return
# Otherwise show a helpful message without disrupting valid selection
widgets = self._get_validation_widgets()
if widgets is None:
return
status_container, status_text, select_button = widgets
# Only show warning if we don't have a valid selection
if select_button.disabled:
status_container.remove_class("status-valid", "status-none")
status_container.add_class("status-invalid")
status_text.update(
"[x] Files cannot be selected\n" "Expand into the parent directory first"
)
self.selected_path = None
[docs]
@on(Button.Pressed, "#select-button")
def select_pressed(self) -> None:
"""
Handle Select button press.
Dismisses the modal with the selected path.
"""
if self.selected_path:
self.dismiss(self.selected_path)
[docs]
@on(Button.Pressed, "#cancel-button")
def cancel_pressed(self) -> None:
"""
Handle Cancel button press.
Dismisses the modal with None.
"""
self.dismiss(None)
# -------------------------------------------------------------------------
# Vim-style navigation actions
# -------------------------------------------------------------------------
[docs]
def action_cursor_down(self) -> None:
"""Move cursor down in directory tree (j key)."""
tree = self.query_one("#directory-tree", DirectoryTree)
tree.action_cursor_down()
[docs]
def action_cursor_up(self) -> None:
"""Move cursor up in directory tree (k key)."""
tree = self.query_one("#directory-tree", DirectoryTree)
tree.action_cursor_up()
[docs]
def action_cursor_left(self) -> None:
"""
Collapse directory in tree (h key).
Collapses the current directory node if expanded.
"""
tree = self.query_one("#directory-tree", DirectoryTree)
# DirectoryTree inherits from Tree which has this action
tree.action_cursor_left() # type: ignore[attr-defined]
[docs]
def action_cursor_right(self) -> None:
"""
Expand directory in tree (l key).
Expands the current directory node if collapsed.
"""
tree = self.query_one("#directory-tree", DirectoryTree)
# DirectoryTree inherits from Tree which has this action
tree.action_cursor_right() # type: ignore[attr-defined]
[docs]
def action_toggle_node(self) -> None:
"""
Toggle expand/collapse of current node (space key).
Expands collapsed nodes, collapses expanded nodes.
"""
tree = self.query_one("#directory-tree", DirectoryTree)
if tree.cursor_node:
tree.cursor_node.toggle()
[docs]
def action_select_node(self) -> None:
"""
Select the current node (enter key).
If the current node is a directory, validates it.
If already valid, confirms the selection.
"""
tree = self.query_one("#directory-tree", DirectoryTree)
if tree.cursor_node and tree.cursor_node.data:
# Get path from node data
if hasattr(tree.cursor_node.data, "path"):
path = Path(tree.cursor_node.data.path)
if path.is_dir():
self._validate_and_update_status(path)
# If valid and already selected, confirm
select_button = self._get_select_button()
if (
select_button is not None
and not select_button.disabled
and self.selected_path == path
):
self.dismiss(self.selected_path)
[docs]
def action_cancel(self) -> None:
"""Cancel and close the dialog (escape key)."""
self.dismiss(None)
[docs]
def action_toggle_hidden(self) -> None:
"""Toggle visibility of hidden files (> key)."""
self.show_hidden = not self.show_hidden
tree = self.query_one("#directory-tree", FilterableDirectoryTree)
tree.show_hidden = self.show_hidden
status = "shown" if self.show_hidden else "hidden"
self.notify(f"Hidden files: {status}", timeout=1.5)
def _get_validation_widgets(self) -> tuple[Static, Label, Button] | None:
"""
Return validation/status widgets if the modal is still mounted.
Returns:
Tuple of ``(status_container, status_text, select_button)`` when all
widgets are available, otherwise ``None``.
"""
try:
status_container = self.query_one("#validation-status", Static)
status_text = self.query_one("#status-text", Label)
select_button = self.query_one("#select-button", Button)
except NoMatches:
return None
return status_container, status_text, select_button
def _get_select_button(self) -> Button | None:
"""
Return the Select button if mounted.
Returns:
The ``#select-button`` widget or ``None`` if unavailable.
"""
try:
return self.query_one("#select-button", Button)
except NoMatches:
return None