1
0
Fork 0
textual-autocomplete/textual_autocomplete/_path_autocomplete.py
Daniel Baumann 0832c185c0
Adding upstream version 4.0.4.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-05-04 22:09:41 +02:00

187 lines
6.9 KiB
Python

from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Callable
from os import DirEntry
from textual.content import Content
from textual.widgets import Input
from textual.cache import LRUCache
from textual_autocomplete import DropdownItem, AutoComplete, TargetState
class PathDropdownItem(DropdownItem):
def __init__(self, completion: str, path: Path) -> None:
super().__init__(completion)
self.path = path
def default_path_input_sort_key(item: PathDropdownItem) -> tuple[bool, bool, str]:
"""Sort key function for results within the dropdown.
Args:
item: The PathDropdownItem to get a sort key for.
Returns:
A tuple of (is_dotfile, is_file, lowercase_name) for sorting.
"""
name = item.path.name
is_dotfile = name.startswith(".")
return (not item.path.is_dir(), not is_dotfile, name.lower())
class PathAutoComplete(AutoComplete):
def __init__(
self,
target: Input | str,
path: str | Path = ".",
*,
show_dotfiles: bool = True,
sort_key: Callable[[PathDropdownItem], Any] = default_path_input_sort_key,
folder_prefix: Content = Content("📂"),
file_prefix: Content = Content("📄"),
prevent_default_enter: bool = True,
prevent_default_tab: bool = True,
cache_size: int = 100,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
"""An autocomplete widget for filesystem paths.
Args:
target: The target input widget to autocomplete.
path: The base path to autocomplete from.
show_dotfiles: Whether to show dotfiles (files/dirs starting with ".").
sort_key: Function to sort the dropdown items.
folder_prefix: The prefix for folder items (e.g. 📂).
file_prefix: The prefix for file items (e.g. 📄).
prevent_default_enter: Whether to prevent the default enter behavior.
prevent_default_tab: Whether to prevent the default tab behavior.
cache_size: The number of directories to cache.
name: The name of the widget.
id: The DOM node id of the widget.
classes: The CSS classes of the widget.
disabled: Whether the widget is disabled.
"""
super().__init__(
target,
None,
prevent_default_enter=prevent_default_enter,
prevent_default_tab=prevent_default_tab,
name=name,
id=id,
classes=classes,
disabled=disabled,
)
self.path = Path(path) if isinstance(path, str) else path
self.show_dotfiles = show_dotfiles
self.sort_key = sort_key
self.folder_prefix = folder_prefix
self.file_prefix = file_prefix
self._directory_cache: LRUCache[str, list[DirEntry[str]]] = LRUCache(cache_size)
def get_candidates(self, target_state: TargetState) -> list[DropdownItem]:
"""Get the candidates for the current path segment.
This is called each time the input changes or the cursor position changes/
"""
current_input = target_state.text[: target_state.cursor_position]
if "/" in current_input:
last_slash_index = current_input.rindex("/")
path_segment = current_input[:last_slash_index] or "/"
directory = self.path / path_segment if path_segment != "/" else self.path
else:
directory = self.path
# Use the directory path as the cache key
cache_key = str(directory)
cached_entries = self._directory_cache.get(cache_key)
if cached_entries is not None:
entries = cached_entries
else:
try:
entries = list(os.scandir(directory))
self._directory_cache[cache_key] = entries
except OSError:
return []
results: list[PathDropdownItem] = []
for entry in entries:
# Only include the entry name, not the full path
completion = entry.name
if not self.show_dotfiles and completion.startswith("."):
continue
if entry.is_dir():
completion += "/"
results.append(PathDropdownItem(completion, path=Path(entry.path)))
results.sort(key=self.sort_key)
folder_prefix = self.folder_prefix
file_prefix = self.file_prefix
return [
DropdownItem(
item.main,
prefix=folder_prefix if item.path.is_dir() else file_prefix,
)
for item in results
]
def get_search_string(self, target_state: TargetState) -> str:
"""Return only the current path segment for searching in the dropdown."""
current_input = target_state.text[: target_state.cursor_position]
if "/" in current_input:
last_slash_index = current_input.rindex("/")
search_string = current_input[last_slash_index + 1 :]
return search_string
else:
return current_input
def apply_completion(self, value: str, state: TargetState) -> None:
"""Apply the completion by replacing only the current path segment."""
target = self.target
current_input = state.text
cursor_position = state.cursor_position
# There's a slash before the cursor, so we only want to replace
# the text after the last slash with the selected value
try:
replace_start_index = current_input.rindex("/", 0, cursor_position)
except ValueError:
# No slashes, so we do a full replacement
new_value = value
new_cursor_position = len(value)
else:
# Keep everything before and including the slash before the cursor.
path_prefix = current_input[: replace_start_index + 1]
new_value = path_prefix + value
new_cursor_position = len(path_prefix) + len(value)
with self.prevent(Input.Changed):
target.value = new_value
target.cursor_position = new_cursor_position
def post_completion(self) -> None:
if not self.target.value.endswith("/"):
self.action_hide()
def should_show_dropdown(self, search_string: str) -> bool:
default_behavior = super().should_show_dropdown(search_string)
return (
default_behavior
or (search_string == "" and self.target.value != "")
and self.option_list.option_count > 1
)
def clear_directory_cache(self) -> None:
"""Clear the directory cache. If you know that the contents of the directory have changed,
you can call this method to invalidate the cache.
"""
self._directory_cache.clear()
target_state = self._get_target_state()
self._rebuild_options(target_state, self.get_search_string(target_state))