1
0
Fork 0

Adding upstream version 0.15.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-02-24 11:29:34 +01:00
parent 0184169650
commit c6da052ee9
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
47 changed files with 6799 additions and 0 deletions

0
src/scripts/__init__.py Normal file
View file

View file

@ -0,0 +1,23 @@
from textual.app import App, ComposeResult
from textual_textarea import TextEditor
class TextApp(App, inherit_bindings=False):
def compose(self) -> ComposeResult:
self.ta = TextEditor(
text="class TextApp(App):",
language="python",
theme="monokai",
use_system_clipboard=True,
id="ta",
)
yield self.ta
def on_mount(self) -> None:
self.ta.focus()
self.exit()
if __name__ == "__main__":
app = TextApp()
app.run()

View file

@ -0,0 +1,20 @@
from textual.app import App, ComposeResult
from textual_textarea import TextEditor
class TextApp(App, inherit_bindings=False):
def compose(self) -> ComposeResult:
self.editor = TextEditor(
language="python",
theme="monokai",
use_system_clipboard=True,
)
yield self.editor
def on_mount(self) -> None:
self.editor.focus()
if __name__ == "__main__":
app = TextApp()
app.run()

32
src/scripts/screenshot.py Normal file
View file

@ -0,0 +1,32 @@
import asyncio
from pathlib import Path
from textual.app import App, ComposeResult
from textual.widgets.text_area import Selection
from textual_textarea import TextEditor
contents = (Path(__file__).parent / "sample_code.py").open("r").read()
class TextApp(App, inherit_bindings=False):
def compose(self) -> ComposeResult:
self.editor = TextEditor(
language="python",
theme="monokai",
use_system_clipboard=True,
)
yield self.editor
def on_mount(self) -> None:
self.editor.focus()
async def take_screenshot() -> None:
app = TextApp()
async with app.run_test(size=(80, 24)):
app.editor.text = contents
app.editor.selection = Selection((7, 12), (8, 12))
app.save_screenshot("textarea.svg")
asyncio.run(take_screenshot())

View file

@ -0,0 +1,15 @@
from textual_textarea.messages import (
TextAreaClipboardError,
TextAreaSaved,
TextAreaThemeError,
)
from textual_textarea.path_input import PathInput
from textual_textarea.text_editor import TextEditor
__all__ = [
"TextEditor",
"PathInput",
"TextAreaClipboardError",
"TextAreaThemeError",
"TextAreaSaved",
]

View file

@ -0,0 +1,67 @@
from __future__ import annotations
import sys
from textual.app import App, ComposeResult
from textual.widgets import Footer, Placeholder
from textual_textarea import TextEditor
class FocusablePlaceholder(Placeholder, can_focus=True):
pass
class TextApp(App, inherit_bindings=False):
BINDINGS = [("ctrl+q", "quit")]
CSS = """
TextEditor {
height: 1fr;
}
Placeholder {
height: 0fr;
}
"""
def compose(self) -> ComposeResult:
try:
language = sys.argv[1]
except IndexError:
language = "sql"
yield FocusablePlaceholder()
self.editor = TextEditor(
language=language,
use_system_clipboard=True,
id="ta",
)
yield self.editor
yield Footer()
def watch_theme(self, theme: str) -> None:
self.editor.theme = theme
def on_mount(self) -> None:
self.theme = "gruvbox"
self.editor.focus()
def _completer(prefix: str) -> list[tuple[tuple[str, str], str]]:
words = [
"satisfy",
"season",
"second",
"seldom",
"select",
"self",
"separate",
"set",
"space",
"super",
"supercalifragilisticexpialadocioussupercalifragilisticexpialadocious",
]
return [((w, "word"), w) for w in words if w.startswith(prefix)]
self.editor.word_completer = _completer
app = TextApp()
app.run()

View file

@ -0,0 +1,289 @@
from __future__ import annotations
from typing import Callable
from rich.console import RenderableType
from rich.style import Style
from rich.text import Text
from textual import on, work
from textual.css.scalar import Scalar, ScalarOffset, Unit
from textual.events import Key, Resize
from textual.geometry import Size
from textual.message import Message
from textual.reactive import Reactive, reactive
from textual.widget import Widget
from textual.widgets import OptionList
from textual.widgets._option_list import NewOptionListContent
from textual.widgets.option_list import Option
from textual_textarea.messages import TextAreaHideCompletionList
class Completion(Option):
def __init__(
self,
prompt: RenderableType,
id: str | None = None, # noqa: A002
disabled: bool = False,
value: str | None = None,
) -> None:
super().__init__(prompt, id, disabled)
self.value = value
class CompletionList(OptionList, can_focus=False, inherit_bindings=False):
COMPONENT_CLASSES = {
"completion-list--type-label",
"completion-list--type-label-highlighted",
}
DEFAULT_CSS = """
CompletionList {
layer: overlay;
padding: 0;
border: none;
width: 40;
max-height: 8;
display: none;
}
CompletionList.open {
display: block;
}
CompletionList .completion-list--type-label {
color: $foreground-muted;
background: transparent;
}
"""
class CompletionsReady(Message, bubble=False):
def __init__(
self,
prefix: str,
items: list[tuple[str, str]] | list[tuple[tuple[str, str], str]],
) -> None:
super().__init__()
self.items = items
self.prefix = prefix
INNER_CONTENT_WIDTH = 37 # should be 3 less than width for scroll bar.
is_open: Reactive[bool] = reactive(False)
cursor_offset: tuple[int, int] = (0, 0)
additional_x_offset: int = 0
def __init__(
self,
*content: NewOptionListContent,
name: str | None = None,
id: str | None = None, # noqa: A002
classes: str | None = None,
disabled: bool = False,
):
super().__init__(
*content, name=name, id=id, classes=classes, disabled=disabled, wrap=False
)
def set_offset(self, x_offset: int, y_offset: int) -> None:
"""The CSS Offset of this widget from its parent."""
self.styles.offset = ScalarOffset.from_offset(
(
x_offset,
y_offset,
)
)
@property
def x_offset(self) -> int:
"""The x-coord of the CSS Offset of this widget from its parent."""
return int(self.styles.offset.x.value)
@property
def y_offset(self) -> int:
"""The y-coord of the CSS Offset of this widget from its parent."""
return int(self.styles.offset.y.value)
@property
def parent_height(self) -> int:
"""
The content size height of the parent widget
"""
return self.parent_size.height
@property
def parent_width(self) -> int:
"""
The content size height of the parent widget
"""
return self.parent_size.width
@property
def parent_size(self) -> Size:
"""
The content size of the parent widget
"""
parent = self.parent
if isinstance(parent, Widget):
return parent.content_size
else:
return self.screen.content_size
@on(CompletionsReady)
def populate_and_position_list(self, event: CompletionsReady) -> None:
event.stop()
self.clear_options()
type_label_style_full = self.get_component_rich_style(
"completion-list--type-label"
)
type_label_fg_style = Style(color=type_label_style_full.color)
prompts = [
Text.assemble(item[0][0], " ", (item[0][1], type_label_fg_style))
if isinstance(item[0], tuple)
else Text.from_markup(item[0])
for item in event.items
]
# if the completions' prompts are wider than the widget,
# we have to trunctate them
max_length = max(map(lambda x: x.cell_len, prompts))
truncate_amount = max(
0,
min(
max_length - self.INNER_CONTENT_WIDTH,
len(event.prefix) - 2,
),
)
if truncate_amount > 0:
additional_x_offset = truncate_amount - 1
items = [
Completion(prompt=f"{prompt[truncate_amount:]}", value=item[1])
for prompt, item in zip(prompts, event.items)
]
else:
additional_x_offset = 0
items = [
Completion(prompt=prompt, value=item[1])
for prompt, item in zip(prompts, event.items)
]
# set x offset if not already open.
if not self.is_open:
try:
x_offset = self._get_x_offset(
prefix_length=len(event.prefix),
additional_x_offset=additional_x_offset,
cursor_x=self.cursor_offset[0],
container_width=self.parent_width,
width=self._width,
)
except ValueError:
x_offset = 0
self.styles.width = self._parent_container_size.width
self.set_offset(x_offset, self.y_offset)
# adjust x offset if we have to due to truncation
elif additional_x_offset != self.additional_x_offset:
self.set_offset(
min(
self.x_offset + (additional_x_offset - self.additional_x_offset),
self.parent_width - self._width,
),
self.y_offset,
)
self.add_options(items=items)
self.action_first()
self.additional_x_offset = additional_x_offset
self.is_open = True
def watch_is_open(self, is_open: bool) -> None:
if not is_open:
self.remove_class("open")
self.additional_x_offset = 0
return
self.add_class("open")
self.styles.max_height = Scalar(
value=8.0, unit=Unit.CELLS, percent_unit=Unit.PERCENT
)
def on_resize(self, event: Resize) -> None:
try:
y_offset = self._get_y_offset(
cursor_y=self.cursor_offset[1],
height=event.size.height,
container_height=self.parent_height,
)
except ValueError:
if self.styles.max_height is not None and self.styles.max_height.value > 1:
self.styles.max_height = Scalar(
value=self.styles.max_height.value - 1,
unit=self.styles.max_height.unit,
percent_unit=self.styles.max_height.percent_unit,
)
else:
self.post_message(TextAreaHideCompletionList())
else:
self.set_offset(self.x_offset, y_offset)
@work(thread=True, exclusive=True, group="completers")
def show_completions(
self,
prefix: str,
completer: Callable[
[str], list[tuple[str, str]] | list[tuple[tuple[str, str], str]]
]
| None,
) -> None:
matches = completer(prefix) if completer is not None else []
if matches:
self.post_message(self.CompletionsReady(prefix=prefix, items=matches))
else:
self.post_message(TextAreaHideCompletionList())
def process_keypress(self, event: Key) -> None:
if event.key in ("tab", "enter", "shift+tab"):
self.action_select()
elif event.key == "up":
self.action_cursor_up()
elif event.key == "down":
self.action_cursor_down()
elif event.key == "pageup":
self.action_page_up()
elif event.key == "pagedown":
self.action_page_down()
@property
def _parent_container_size(self) -> Size:
return getattr(self.parent, "container_size", self.screen.container_size)
@property
def _width(self) -> int:
if self.styles.width and self.styles.width.unit == Unit.CELLS:
return int(self.styles.width.value)
else:
return self.outer_size.width
@staticmethod
def _get_x_offset(
prefix_length: int,
additional_x_offset: int,
cursor_x: int,
container_width: int,
width: int,
) -> int:
x = cursor_x - prefix_length + additional_x_offset
max_x = container_width - width
if max_x < 0:
raise ValueError("doesn't fit")
return min(x, max_x)
@staticmethod
def _get_y_offset(cursor_y: int, height: int, container_height: int) -> int:
fits_above = height < cursor_y + 1
fits_below = height < container_height - cursor_y
if fits_below:
y = cursor_y + 1
elif fits_above:
y = cursor_y - height
else:
raise ValueError("Doesn't fit.")
return y

View file

@ -0,0 +1,19 @@
from textual.binding import Binding
from textual.message import Message
from textual.widgets import Input
class CancellableInput(Input):
BINDINGS = [
Binding("escape", "cancel", "Cancel", show=False),
]
class Cancelled(Message):
"""
Posted when the user presses Esc to cancel the input.
"""
pass
def action_cancel(self) -> None:
self.post_message(self.Cancelled())

View file

@ -0,0 +1,67 @@
from __future__ import annotations
from rich.style import Style
from textual.color import Color
from textual.theme import Theme
from textual.widgets.text_area import TextAreaTheme
def text_area_theme_from_app_theme(
theme_name: str, theme: Theme, css_vars: dict[str, str]
) -> TextAreaTheme:
builtin = TextAreaTheme.get_builtin_theme(theme_name)
if builtin is not None:
return builtin
if "background" in css_vars:
background_color = Color.parse(
css_vars.get("background", "#000000" if theme.dark else "#FFFFFF")
)
foreground_color = Color.parse(
css_vars.get("foreground", background_color.inverse)
)
else:
foreground_color = Color.parse(
css_vars.get("foreground", "#FFFFFF" if theme.dark else "#000000")
)
background_color = foreground_color.inverse
muted = background_color.blend(foreground_color, factor=0.5)
computed_theme = TextAreaTheme(
name=theme_name,
base_style=Style(
color=foreground_color.rich_color, bgcolor=background_color.rich_color
),
syntax_styles={
"comment": muted.hex, # type: ignore
"string": theme.accent, # type: ignore
"string.documentation": muted.hex, # type: ignore
"string.special": theme.accent, # type: ignore
"number": theme.accent, # type: ignore
"float": theme.accent, # type: ignore
"function": theme.secondary, # type: ignore
"function.call": theme.secondary, # type: ignore
"method": theme.secondary, # type: ignore
"method.call": theme.secondary, # type: ignore
"constant": foreground_color.hex, # type: ignore
"constant.builtin": foreground_color.hex, # type: ignore
"boolean": theme.accent, # type: ignore
"class": f"{foreground_color.hex} bold", # type: ignore
"type": f"{foreground_color.hex} bold", # type: ignore
"variable": foreground_color.hex, # type: ignore
"parameter": f"{theme.accent} bold", # type: ignore
"operator": theme.secondary, # type: ignore
"punctuation.bracket": foreground_color.hex, # type: ignore
"punctuation.delimeter": foreground_color.hex, # type: ignore
"keyword": f"{theme.primary} bold", # type: ignore
"keyword.function": theme.secondary, # type: ignore
"keyword.return": theme.primary, # type: ignore
"keyword.operator": f"{theme.primary} bold", # type: ignore
"exception": theme.error, # type: ignore
"heading": theme.primary, # type: ignore
"bold": "bold", # type: ignore
"italic": "italic", # type: ignore
},
)
return computed_theme

View file

@ -0,0 +1,185 @@
INLINE_MARKERS = {
"abap": '"',
"actionscript": "//",
"as": "//",
"actionscript3": "//",
"as3": "//",
"ada": "--",
"ada95": "--",
"ada2005": "--",
"antlr-objc": "//",
"apl": "",
"applescript": "--",
"autohotkey": ";",
"ahk": ";",
"autoit": ";",
"basemake": "#",
"bash": "#",
"sh": "#",
"ksh": "#",
"zsh": "#",
"shell": "#",
"batch": "::",
"bat": "::",
"dosbatch": "::",
"winbatch": "::",
"bbcbasic": "REM",
"blitzbasic": "REM",
"b3d": "REM",
"bplus": "REM",
"boo": "#",
"c": "//",
"csharp": "//",
"c#": "//",
"cs": "//",
"cpp": "//",
"c++": "//",
"cbmbas": "REM",
"clojure": ";",
"clj": ";",
"clojurescript": ";",
"cljs": ";",
"cmake": "#",
"cobol": "*>",
"cobolfree": "*>",
"common-lisp": ";",
"cl": ";",
"lisp": ";",
"d": "//",
"delphi": "//",
"pas": "//",
"pascal": "//",
"objectpascal": "//",
"eiffel": "--",
"elixir": "#",
"ex": "#",
"exs": "#",
"iex": "#",
"elm": "--",
"emacs-lisp": ";",
"elisp": ";",
"emacs": ";",
"erlang": "%",
"erl": "%",
"fsharp": "//",
"f#": "//",
"factor": "!",
"fish": "#",
"fishshell": "#",
"forth": "\\",
"fortran": "!",
"f90": "!",
"fortranfixed": "!",
"go": "//",
"golang": "//",
"haskell": "--",
"hs": "--",
"inform6": "!",
"i6": "!",
"i6t": "!",
"inform7": "!",
"i7": "!",
"j": "NB.",
"java": "//",
"jsp": "//",
"javascript": "//",
"js": "//",
"julia": "#",
"jl": "#",
"jlcon": "#",
"julia-repl": "#",
"kotlin": "//",
"lua": "--",
"make": "#",
"makefile": "#",
"mf": "#",
"bsdmake": "#",
"matlab": "%",
"matlabsession": "%",
"monkey": "'",
"mysql": "#",
"newlisp": ";",
"nimrod": "#",
"nim": "#",
"objective-c": "//",
"objectivec": "//",
"obj-c": "//",
"objc": "//",
"objective-c++": "//",
"objectivec++": "//",
"obj-c++": "//",
"objc++": "//",
"perl": "#",
"pl": "#",
"perl6": "#",
"pl6": "#",
"raku": "#",
"php": "#",
"php3": "#",
"php4": "#",
"php5": "#",
"plpgsql": "--",
"psql": "--",
"postgresql-console": "--",
"postgres-console": "--",
"postgres-explain": "--",
"postgresql": "--",
"postgres": "--",
"postscript": "%",
"postscr": "%",
"powershell": "#",
"pwsh": "#",
"posh": "#",
"ps1": "#",
"psm1": "#",
"pwsh-session": "#",
"ps1con": "#",
"prolog": "%",
"python": "#",
"py": "#",
"sage": "#",
"python3": "#",
"py3": "#",
"python2": "#",
"py2": "#",
"py2tb": "#",
"pycon": "#",
"pytb": "#",
"py3tb": "#",
"py+ul4": "#",
"qbasic": "REM",
"basic": "REM",
"ragel-ruby": "#",
"ragel-rb": "#",
"rebol": ";",
"red": ";",
"red/system": ";",
"ruby": "#",
"rb": "#",
"duby": "#",
"rbcon": "#",
"irb": "#",
"rust": "//",
"rs": "//",
"sass": "//",
"scala": "//",
"scheme": ";",
"scm": ";",
"sql": "--",
"sql+jinja": "--",
"sqlite3": "--",
"swift": "//",
"tex": "%",
"latex": "%",
"tsql": "--",
"t-sql": "--",
"vbscript": "'",
"vhdl": "--",
"wast": ";;",
"wat": ";;",
"yaml": "#",
"yaml+jinja": "#",
"salt": "#",
"sls": "#",
"zig": "//",
}

View file

@ -0,0 +1,54 @@
from typing import Any, Union
from textual.containers import Container, ScrollableContainer
from textual.widget import Widget
class TextContainer(
ScrollableContainer,
inherit_bindings=False,
can_focus=False,
can_focus_children=True,
):
DEFAULT_CSS = """
TextContainer {
height: 1fr;
width: 100%;
layers: main overlay;
}
"""
def scroll_to(
self, x: Union[float, None] = None, y: Union[float, None] = None, **_: Any
) -> None:
return super().scroll_to(x, y, animate=True, duration=0.01)
class FooterContainer(
Container,
inherit_bindings=False,
can_focus=False,
can_focus_children=True,
):
DEFAULT_CSS = """
FooterContainer {
dock: bottom;
height: auto;
width: 100%
}
FooterContainer.hide {
height: 0;
}
"""
def __init__(
self,
*children: Widget,
name: Union[str, None] = None,
id: Union[str, None] = None, # noqa: A002
classes: Union[str, None] = None,
disabled: bool = False,
) -> None:
super().__init__(
*children, name=name, id=id, classes=classes, disabled=disabled
)

View file

@ -0,0 +1,75 @@
from typing import Union
from textual.app import ComposeResult
from textual.containers import Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Static
class ErrorModal(ModalScreen):
DEFAULT_CSS = """
ErrorModal {
align: center middle;
padding: 0;
}
#error_modal__outer {
border: round $error;
background: $background;
margin: 5 10;
padding: 1 2;
max-width: 88;
}
#error_modal__header {
dock: top;
color: $text-muted;
margin: 0 0 1 0;
padding: 0 1;
}
#error_modal__inner {
border: round $background;
padding: 1 1 1 2;
}
#error_modal__info {
padding: 0 3 0 0;
}
#error_modal__footer {
dock: bottom;
color: $text-muted;
margin: 1 0 0 0;
padding: 0 1;
}
"""
def __init__(
self,
title: str,
header: str,
error: BaseException,
name: Union[str, None] = None,
id: Union[str, None] = None, # noqa: A002
classes: Union[str, None] = None,
) -> None:
super().__init__(name, id, classes)
self.title = title
self.header = header
self.error = error
def compose(self) -> ComposeResult:
with Vertical(id="error_modal__outer"):
yield Static(self.header, id="error_modal__header")
with Vertical(id="error_modal__inner"):
with VerticalScroll():
yield Static(str(self.error), id="error_modal__info")
yield Static("Press any key to continue.", id="error_modal__footer")
def on_mount(self) -> None:
container = self.query_one("#error_modal__outer")
container.border_title = self.title
def on_key(self) -> None:
self.app.pop_screen()
self.app.action_focus_next()

View file

@ -0,0 +1,77 @@
from __future__ import annotations
from textual import on
from textual.events import Blur, Key
from textual.widgets import Input
from textual_textarea.cancellable_input import CancellableInput
class FindInput(CancellableInput):
def __init__(
self,
value: str = "",
history: list[str] | None = None,
classes: str | None = None,
) -> None:
super().__init__(
value=value,
placeholder="Find; enter for next; ESC to close; ↑↓ for history",
password=False,
type="text",
id="textarea__find_input",
classes=classes,
)
self.history: list[str] = [] if history is None else history
self.history_index: int | None = None
@on(Key)
def handle_special_keys(self, event: Key) -> None:
if event.key not in ("up", "down", "f3"):
self.history_index = None
return
event.stop()
event.prevent_default()
if event.key == "down":
self._handle_down()
elif event.key == "up":
self._handle_up()
elif event.key == "f3":
self.post_message(Input.Submitted(self, self.value))
@on(Blur)
def handle_blur(self) -> None:
if self.value and (not self.history or self.value != self.history[-1]):
self.history.append(self.value)
def _handle_down(self) -> None:
if self.history_index is None:
self.checkpoint()
self.value = ""
elif self.history_index == -1:
self.history_index = None
self.value = ""
else:
self.history_index += 1
self.value = self.history[self.history_index]
self.action_end()
def checkpoint(self) -> bool:
if self.value and (not self.history or self.value != self.history[-1]):
self.history.append(self.value)
return True
return False
def _handle_up(self) -> None:
if not self.history:
if self.value:
self.history.append(self.value)
self.value = ""
return
if self.history_index is None:
self.history_index = -1 if self.checkpoint() else 0
self.history_index = max(-1 * len(self.history), self.history_index - 1)
self.value = self.history[self.history_index]
self.action_end()

View file

@ -0,0 +1,60 @@
from __future__ import annotations
from textual.validation import ValidationResult, Validator
from textual_textarea.cancellable_input import CancellableInput
class GotoLineValidator(Validator):
def __init__(
self,
max_line_number: int,
min_line_number: int = 1,
failure_description: str = "Not a valid line number.",
) -> None:
super().__init__(failure_description)
self.max_line_number = max_line_number
self.min_line_number = min_line_number
def validate(self, value: str) -> ValidationResult:
try:
lno = int(value)
except (ValueError, TypeError):
return self.failure("Not a valid line number.")
if lno < self.min_line_number:
return self.failure(f"Line number must be >= {self.min_line_number}")
elif lno > self.max_line_number:
return self.failure(f"Line number must be <= {self.max_line_number}")
return self.success()
class GotoLineInput(CancellableInput):
def __init__(
self,
*,
max_line_number: int,
id: str | None = None, # noqa: A002
classes: str | None = None,
current_line: int | None = None,
min_line_number: int = 1,
) -> None:
current_line_text = (
f"Current line: {current_line}. " if current_line is not None else ""
)
range_text = (
f"Enter a line number between {min_line_number} and " f"{max_line_number}."
)
placeholder = f"{current_line_text}{range_text} ESC to cancel."
super().__init__(
"",
placeholder=placeholder,
type="integer",
validators=GotoLineValidator(
max_line_number=max_line_number, min_line_number=min_line_number
),
validate_on={"changed"},
id=id,
classes=classes,
)

View file

@ -0,0 +1,38 @@
from pathlib import Path
from typing import Union
from textual.message import Message
class TextAreaClipboardError(Message, bubble=True):
"""
Posted when textarea cannot access the system clipboard
"""
def __init__(self, action: str) -> None:
super().__init__()
self.action = action
class TextAreaThemeError(Message, bubble=True):
"""
Posted when textarea cannot instantiate a theme
"""
def __init__(self, theme: str) -> None:
super().__init__()
self.theme = theme
class TextAreaSaved(Message, bubble=True):
"""
Posted when the textarea saved a file successfully.
"""
def __init__(self, path: Union[Path, str]) -> None:
self.path = str(path)
super().__init__()
class TextAreaHideCompletionList(Message):
pass

View file

@ -0,0 +1,127 @@
from __future__ import annotations
import stat
from pathlib import Path
from rich.highlighter import Highlighter
from textual.binding import Binding
from textual.suggester import Suggester
from textual.validation import ValidationResult, Validator
from textual_textarea.cancellable_input import CancellableInput
def path_completer(prefix: str) -> list[tuple[str, str]]:
try:
original = Path(prefix)
p = original.expanduser()
if p.is_dir():
matches = list(p.iterdir())
else:
matches = list(p.parent.glob(f"{p.name}*"))
if original != p and original.parts and original.parts[0] == "~":
prompts = [str(Path("~") / m.relative_to(Path.home())) for m in matches]
elif not original.is_absolute() and prefix.startswith("./"):
prompts = [f"./{m}" for m in matches]
else:
prompts = [str(m) for m in matches]
return [(p, p) for p in prompts]
except Exception:
return []
class PathSuggester(Suggester):
def __init__(self) -> None:
super().__init__(use_cache=True, case_sensitive=True)
async def get_suggestion(self, value: str) -> str | None:
matches = path_completer(value)
if len(matches) == 1:
return str(matches[0][0])
else:
return None
class PathValidator(Validator):
def __init__(
self,
dir_okay: bool,
file_okay: bool,
must_exist: bool,
failure_description: str = "Not a valid path.",
) -> None:
self.dir_okay = dir_okay
self.file_okay = file_okay
self.must_exist = must_exist
super().__init__(failure_description)
def validate(self, value: str) -> ValidationResult:
if self.dir_okay and self.file_okay and not self.must_exist:
return self.success()
try:
p = Path(value).expanduser().resolve()
except Exception:
return self.failure("Not a valid path.")
try:
st = p.stat()
except FileNotFoundError:
if self.must_exist:
return self.failure("File or directory does not exist.")
return self.success()
if not self.dir_okay and stat.S_ISDIR(st.st_mode):
return self.failure("Path cannot be a directory.")
elif not self.file_okay and stat.S_ISREG(st.st_mode):
return self.failure("Path cannot be a regular file.")
return self.success()
class PathInput(CancellableInput):
BINDINGS = [
Binding("tab", "complete", "Accept Completion", show=False),
]
def __init__(
self,
value: str | None = None,
placeholder: str = "",
highlighter: Highlighter | None = None,
password: bool = False,
*,
name: str | None = None,
id: str | None = None, # noqa: A002
classes: str | None = None,
disabled: bool = False,
dir_okay: bool = True,
file_okay: bool = True,
must_exist: bool = False,
tab_advances_focus: bool = False,
) -> None:
self.tab_advances_focus = tab_advances_focus
super().__init__(
value,
placeholder,
highlighter,
password,
suggester=PathSuggester(),
validators=PathValidator(dir_okay, file_okay, must_exist),
name=name,
id=id,
classes=classes,
disabled=disabled,
)
def action_complete(self) -> None:
if self._suggestion and self._suggestion != self.value:
self.action_cursor_right()
elif self.tab_advances_focus:
self.app.action_focus_next()
def _toggle_cursor(self) -> None:
"""Toggle visibility of cursor."""
if self.app.is_headless:
self._cursor_visible = True
else:
self._cursor_visible = not self._cursor_visible

View file

File diff suppressed because it is too large Load diff