1122 lines
40 KiB
Python
1122 lines
40 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, ClassVar, Iterable, NamedTuple
|
|
|
|
from rich.cells import cell_len, get_character_cell_size
|
|
from rich.console import RenderableType
|
|
from rich.highlighter import Highlighter
|
|
from rich.text import Text
|
|
from typing_extensions import Literal
|
|
|
|
from textual import events
|
|
from textual.actions import SkipAction
|
|
from textual.expand_tabs import expand_tabs_inline
|
|
from textual.screen import Screen
|
|
from textual.scroll_view import ScrollView
|
|
from textual.strip import Strip
|
|
|
|
if TYPE_CHECKING:
|
|
pass
|
|
|
|
from textual.binding import Binding, BindingType
|
|
from textual.css._error_tools import friendly_list
|
|
from textual.events import Blur, Focus, Mount
|
|
from textual.geometry import Offset, Region, Size, clamp
|
|
from textual.message import Message
|
|
from textual.reactive import Reactive, reactive, var
|
|
from textual.suggester import Suggester, SuggestionReady
|
|
from textual.timer import Timer
|
|
from textual.validation import ValidationResult, Validator
|
|
|
|
InputValidationOn = Literal["blur", "changed", "submitted"]
|
|
"""Possible messages that trigger input validation."""
|
|
_POSSIBLE_VALIDATE_ON_VALUES = {"blur", "changed", "submitted"}
|
|
"""Set literal with the legal values for the type `InputValidationOn`."""
|
|
|
|
_RESTRICT_TYPES = {
|
|
"integer": r"[-+]?(?:\d*|\d+_)*",
|
|
"number": r"[-+]?(?:\d*|\d+_)*\.?(?:\d*|\d+_)*(?:\d[eE]?[-+]?(?:\d*|\d+_)*)?",
|
|
"text": None,
|
|
}
|
|
InputType = Literal["integer", "number", "text"]
|
|
|
|
|
|
class Selection(NamedTuple):
|
|
"""A range of selected text within the Input.
|
|
|
|
Text can be selected by clicking and dragging the mouse, or by pressing
|
|
shift+arrow keys.
|
|
|
|
Attributes:
|
|
start: The start index of the selection.
|
|
end: The end index of the selection.
|
|
"""
|
|
|
|
start: int
|
|
end: int
|
|
|
|
@classmethod
|
|
def cursor(cls, cursor_position: int) -> Selection:
|
|
"""Create a selection from a cursor position."""
|
|
return cls(cursor_position, cursor_position)
|
|
|
|
@property
|
|
def is_empty(self) -> bool:
|
|
"""Return True if the selection is empty."""
|
|
return self.start == self.end
|
|
|
|
|
|
class Input(ScrollView):
|
|
"""A text input widget."""
|
|
|
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
Binding("left", "cursor_left", "Move cursor left", show=False),
|
|
Binding(
|
|
"shift+left",
|
|
"cursor_left(True)",
|
|
"Move cursor left and select",
|
|
show=False,
|
|
),
|
|
Binding("ctrl+left", "cursor_left_word", "Move cursor left a word", show=False),
|
|
Binding(
|
|
"ctrl+shift+left",
|
|
"cursor_left_word(True)",
|
|
"Move cursor left a word and select",
|
|
show=False,
|
|
),
|
|
Binding(
|
|
"right",
|
|
"cursor_right",
|
|
"Move cursor right or accept the completion suggestion",
|
|
show=False,
|
|
),
|
|
Binding(
|
|
"shift+right",
|
|
"cursor_right(True)",
|
|
"Move cursor right and select",
|
|
show=False,
|
|
),
|
|
Binding(
|
|
"ctrl+right",
|
|
"cursor_right_word",
|
|
"Move cursor right a word",
|
|
show=False,
|
|
),
|
|
Binding(
|
|
"ctrl+shift+right",
|
|
"cursor_right_word(True)",
|
|
"Move cursor right a word and select",
|
|
show=False,
|
|
),
|
|
Binding("backspace", "delete_left", "Delete character left", show=False),
|
|
Binding("ctrl+shift+a", "select_all", "Select all", show=False),
|
|
Binding("home,ctrl+a", "home", "Go to start", show=False),
|
|
Binding("end,ctrl+e", "end", "Go to end", show=False),
|
|
Binding("shift+home", "home(True)", "Select line start", show=False),
|
|
Binding("shift+end", "end(True)", "Select line end", show=False),
|
|
Binding("delete,ctrl+d", "delete_right", "Delete character right", show=False),
|
|
Binding("enter", "submit", "Submit", show=False),
|
|
Binding(
|
|
"ctrl+w", "delete_left_word", "Delete left to start of word", show=False
|
|
),
|
|
Binding("ctrl+u", "delete_left_all", "Delete all to the left", show=False),
|
|
Binding(
|
|
"ctrl+f", "delete_right_word", "Delete right to start of word", show=False
|
|
),
|
|
Binding("ctrl+k", "delete_right_all", "Delete all to the right", show=False),
|
|
Binding("ctrl+x", "cut", "Cut selected text", show=False),
|
|
Binding("ctrl+c", "copy", "Copy selected text", show=False),
|
|
Binding("ctrl+v", "paste", "Paste text from the clipboard", show=False),
|
|
]
|
|
"""
|
|
| Key(s) | Description |
|
|
| :- | :- |
|
|
| left | Move the cursor left. |
|
|
| shift+left | Move cursor left and select. |
|
|
| ctrl+left | Move the cursor one word to the left. |
|
|
| right | Move the cursor right or accept the completion suggestion. |
|
|
| ctrl+shift+left | Move cursor left a word and select. |
|
|
| shift+right | Move cursor right and select. |
|
|
| ctrl+right | Move the cursor one word to the right. |
|
|
| backspace | Delete the character to the left of the cursor. |
|
|
| ctrl+shift+right | Move cursor right a word and select. |
|
|
| ctrl+shift+a | Select all text in the input. |
|
|
| home,ctrl+a | Go to the beginning of the input. |
|
|
| end,ctrl+e | Go to the end of the input. |
|
|
| shift+home | Select up to the input start. |
|
|
| shift+end | Select up to the input end. |
|
|
| delete,ctrl+d | Delete the character to the right of the cursor. |
|
|
| enter | Submit the current value of the input. |
|
|
| ctrl+w | Delete the word to the left of the cursor. |
|
|
| ctrl+u | Delete everything to the left of the cursor. |
|
|
| ctrl+f | Delete the word to the right of the cursor. |
|
|
| ctrl+k | Delete everything to the right of the cursor. |
|
|
| ctrl+x | Cut selected text. |
|
|
| ctrl+c | Copy selected text. |
|
|
| ctrl+v | Paste text from the clipboard. |
|
|
"""
|
|
|
|
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
|
"input--cursor",
|
|
"input--placeholder",
|
|
"input--suggestion",
|
|
"input--selection",
|
|
}
|
|
"""
|
|
| Class | Description |
|
|
| :- | :- |
|
|
| `input--cursor` | Target the cursor. |
|
|
| `input--placeholder` | Target the placeholder text (when it exists). |
|
|
| `input--suggestion` | Target the auto-completion suggestion (when it exists). |
|
|
| `input--selection` | Target the selected text. |
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
Input {
|
|
background: $surface;
|
|
color: $foreground;
|
|
padding: 0 2;
|
|
border: tall $border-blurred;
|
|
width: 100%;
|
|
height: 3;
|
|
scrollbar-size-horizontal: 0;
|
|
|
|
&.-textual-compact {
|
|
border: none !important;
|
|
height: 1;
|
|
padding: 0;
|
|
&.-invalid {
|
|
background-tint: $error 20%;
|
|
}
|
|
}
|
|
|
|
&:focus {
|
|
border: tall $border;
|
|
background-tint: $foreground 5%;
|
|
}
|
|
&>.input--cursor {
|
|
background: $input-cursor-background;
|
|
color: $input-cursor-foreground;
|
|
text-style: $input-cursor-text-style;
|
|
}
|
|
&>.input--selection {
|
|
background: $input-selection-background;
|
|
}
|
|
&>.input--placeholder, &>.input--suggestion {
|
|
color: $text-disabled;
|
|
}
|
|
&.-invalid {
|
|
border: tall $error 60%;
|
|
}
|
|
&.-invalid:focus {
|
|
border: tall $error;
|
|
}
|
|
|
|
&:ansi {
|
|
background: ansi_default;
|
|
color: ansi_default;
|
|
&>.input--cursor {
|
|
background: ansi_white;
|
|
color: ansi_black;
|
|
}
|
|
&>.input--placeholder, &>.input--suggestion {
|
|
text-style: dim;
|
|
color: ansi_default;
|
|
}
|
|
&.-invalid {
|
|
border: tall ansi_red;
|
|
}
|
|
&.-invalid:focus {
|
|
border: tall ansi_red;
|
|
}
|
|
}
|
|
}
|
|
|
|
"""
|
|
|
|
cursor_blink = reactive(True, init=False)
|
|
# TODO - check with width: auto to see if layout=True is needed
|
|
value: Reactive[str] = reactive("", init=False)
|
|
|
|
@property
|
|
def cursor_position(self) -> int:
|
|
"""The current position of the cursor, corresponding to the end of the selection."""
|
|
return self.selection.end
|
|
|
|
@cursor_position.setter
|
|
def cursor_position(self, position: int) -> None:
|
|
"""Set the current position of the cursor."""
|
|
self.selection = Selection.cursor(position)
|
|
|
|
selection: Reactive[Selection] = reactive(Selection.cursor(0))
|
|
"""The currently selected range of text."""
|
|
|
|
placeholder = reactive("")
|
|
_cursor_visible = reactive(True)
|
|
password = reactive(False)
|
|
suggester: Suggester | None
|
|
"""The suggester used to provide completions as the user types."""
|
|
_suggestion = reactive("")
|
|
"""A completion suggestion for the current value in the input."""
|
|
restrict = var["str | None"](None)
|
|
"""A regular expression to limit changes in value."""
|
|
type = var[InputType]("text")
|
|
"""The type of the input."""
|
|
max_length = var["int | None"](None)
|
|
"""The maximum length of the input, in characters."""
|
|
valid_empty = var(False)
|
|
"""Empty values should pass validation."""
|
|
compact = reactive(False, toggle_class="-textual-compact")
|
|
"""Make the input compact (without borders)."""
|
|
|
|
@dataclass
|
|
class Changed(Message):
|
|
"""Posted when the value changes.
|
|
|
|
Can be handled using `on_input_changed` in a subclass of `Input` or in a parent
|
|
widget in the DOM.
|
|
"""
|
|
|
|
input: Input
|
|
"""The `Input` widget that was changed."""
|
|
|
|
value: str
|
|
"""The value that the input was changed to."""
|
|
|
|
validation_result: ValidationResult | None = None
|
|
"""The result of validating the value (formed by combining the results from each validator), or None
|
|
if validation was not performed (for example when no validators are specified in the `Input`s init)"""
|
|
|
|
@property
|
|
def control(self) -> Input:
|
|
"""Alias for self.input."""
|
|
return self.input
|
|
|
|
@dataclass
|
|
class Submitted(Message):
|
|
"""Posted when the enter key is pressed within an `Input`.
|
|
|
|
Can be handled using `on_input_submitted` in a subclass of `Input` or in a
|
|
parent widget in the DOM.
|
|
"""
|
|
|
|
input: Input
|
|
"""The `Input` widget that is being submitted."""
|
|
value: str
|
|
"""The value of the `Input` being submitted."""
|
|
validation_result: ValidationResult | None = None
|
|
"""The result of validating the value on submission, formed by combining the results for each validator.
|
|
This value will be None if no validation was performed, which will be the case if no validators are supplied
|
|
to the corresponding `Input` widget."""
|
|
|
|
@property
|
|
def control(self) -> Input:
|
|
"""Alias for self.input."""
|
|
return self.input
|
|
|
|
@dataclass
|
|
class Blurred(Message):
|
|
"""Posted when the widget is blurred (loses focus).
|
|
|
|
Can be handled using `on_input_blurred` in a subclass of `Input` or in a parent
|
|
widget in the DOM.
|
|
"""
|
|
|
|
input: Input
|
|
"""The `Input` widget that was changed."""
|
|
|
|
value: str
|
|
"""The value that the input was changed to."""
|
|
|
|
validation_result: ValidationResult | None = None
|
|
"""The result of validating the value (formed by combining the results from each validator), or None
|
|
if validation was not performed (for example when no validators are specified in the `Input`s init)"""
|
|
|
|
@property
|
|
def control(self) -> Input:
|
|
"""Alias for self.input."""
|
|
return self.input
|
|
|
|
def __init__(
|
|
self,
|
|
value: str | None = None,
|
|
placeholder: str = "",
|
|
highlighter: Highlighter | None = None,
|
|
password: bool = False,
|
|
*,
|
|
restrict: str | None = None,
|
|
type: InputType = "text",
|
|
max_length: int = 0,
|
|
suggester: Suggester | None = None,
|
|
validators: Validator | Iterable[Validator] | None = None,
|
|
validate_on: Iterable[InputValidationOn] | None = None,
|
|
valid_empty: bool = False,
|
|
select_on_focus: bool = True,
|
|
name: str | None = None,
|
|
id: str | None = None,
|
|
classes: str | None = None,
|
|
disabled: bool = False,
|
|
tooltip: RenderableType | None = None,
|
|
compact: bool = False,
|
|
) -> None:
|
|
"""Initialise the `Input` widget.
|
|
|
|
Args:
|
|
value: An optional default value for the input.
|
|
placeholder: Optional placeholder text for the input.
|
|
highlighter: An optional highlighter for the input.
|
|
password: Flag to say if the field should obfuscate its content.
|
|
restrict: A regex to restrict character inputs.
|
|
type: The type of the input.
|
|
max_length: The maximum length of the input, or 0 for no maximum length.
|
|
suggester: [`Suggester`][textual.suggester.Suggester] associated with this
|
|
input instance.
|
|
validators: An iterable of validators that the Input value will be checked against.
|
|
validate_on: Zero or more of the values "blur", "changed", and "submitted",
|
|
which determine when to do input validation. The default is to do
|
|
validation for all messages.
|
|
valid_empty: Empty values are valid.
|
|
select_on_focus: Whether to select all text on focus.
|
|
name: Optional name for the input widget.
|
|
id: Optional ID for the widget.
|
|
classes: Optional initial classes for the widget.
|
|
disabled: Whether the input is disabled or not.
|
|
tooltip: Optional tooltip.
|
|
compact: Enable compact style (without borders).
|
|
"""
|
|
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
|
|
|
self._blink_timer: Timer | None = None
|
|
"""Timer controlling the blinking of the cursor, instantiated in `on_mount`."""
|
|
|
|
self.placeholder = placeholder
|
|
self.highlighter = highlighter
|
|
self.password = password
|
|
self.suggester = suggester
|
|
|
|
# Ensure we always end up with an Iterable of validators
|
|
if isinstance(validators, Validator):
|
|
self.validators: list[Validator] = [validators]
|
|
elif validators is None:
|
|
self.validators = []
|
|
else:
|
|
self.validators = list(validators)
|
|
|
|
self.validate_on: set[str] = (
|
|
(_POSSIBLE_VALIDATE_ON_VALUES & set(validate_on))
|
|
if validate_on is not None
|
|
else _POSSIBLE_VALIDATE_ON_VALUES
|
|
)
|
|
"""Set with event names to do input validation on.
|
|
|
|
Validation can only be performed on blur, on input changes and on input submission.
|
|
|
|
Example:
|
|
This creates an `Input` widget that only gets validated when the value
|
|
is submitted explicitly:
|
|
|
|
```py
|
|
input = Input(validate_on=["submitted"])
|
|
```
|
|
"""
|
|
self._reactive_valid_empty = valid_empty
|
|
self._valid = True
|
|
|
|
self.restrict = restrict
|
|
if type not in _RESTRICT_TYPES:
|
|
raise ValueError(
|
|
f"Input type must be one of {friendly_list(_RESTRICT_TYPES.keys())}; not {type!r}"
|
|
)
|
|
self.type = type
|
|
self.max_length = max_length
|
|
if not self.validators:
|
|
from textual.validation import Integer, Number
|
|
|
|
if self.type == "integer":
|
|
self.validators.append(Integer())
|
|
elif self.type == "number":
|
|
self.validators.append(Number())
|
|
|
|
self._selecting = False
|
|
"""True if the user is selecting text with the mouse."""
|
|
|
|
self._initial_value = True
|
|
"""Indicates if the value has been set for the first time yet."""
|
|
if value is not None:
|
|
self.value = value
|
|
|
|
if tooltip is not None:
|
|
self.tooltip = tooltip
|
|
|
|
self.compact = compact
|
|
|
|
self.select_on_focus = select_on_focus
|
|
|
|
def _position_to_cell(self, position: int) -> int:
|
|
"""Convert an index within the value to cell position.
|
|
|
|
Args:
|
|
position: The index within the value to convert.
|
|
|
|
Returns:
|
|
The cell position corresponding to the index.
|
|
"""
|
|
return cell_len(expand_tabs_inline(self.value[:position], 4))
|
|
|
|
@property
|
|
def _cursor_offset(self) -> int:
|
|
"""The cell offset of the cursor."""
|
|
offset = self._position_to_cell(self.cursor_position)
|
|
if self.cursor_at_end:
|
|
offset += 1
|
|
return offset
|
|
|
|
@property
|
|
def cursor_at_start(self) -> bool:
|
|
"""Flag to indicate if the cursor is at the start."""
|
|
return self.cursor_position == 0
|
|
|
|
@property
|
|
def cursor_at_end(self) -> bool:
|
|
"""Flag to indicate if the cursor is at the end."""
|
|
return self.cursor_position == len(self.value)
|
|
|
|
def check_consume_key(self, key: str, character: str | None) -> bool:
|
|
"""Check if the widget may consume the given key.
|
|
|
|
As an input we are expecting to capture printable keys.
|
|
|
|
Args:
|
|
key: A key identifier.
|
|
character: A character associated with the key, or `None` if there isn't one.
|
|
|
|
Returns:
|
|
`True` if the widget may capture the key in its `Key` message, or `False` if it won't.
|
|
"""
|
|
return character is not None and character.isprintable()
|
|
|
|
def validate_selection(self, selection: Selection) -> Selection:
|
|
start, end = selection
|
|
value_length = len(self.value)
|
|
return Selection(clamp(start, 0, value_length), clamp(end, 0, value_length))
|
|
|
|
def _watch_selection(self, selection: Selection) -> None:
|
|
self.app.clear_selection()
|
|
self.app.cursor_position = self.cursor_screen_offset
|
|
if not self._initial_value:
|
|
self.scroll_to_region(
|
|
Region(self._cursor_offset, 0, width=1, height=1),
|
|
force=True,
|
|
animate=False,
|
|
)
|
|
|
|
def _watch_cursor_blink(self, blink: bool) -> None:
|
|
"""Ensure we handle updating the cursor blink at runtime."""
|
|
if self._blink_timer is not None:
|
|
if blink:
|
|
self._blink_timer.resume()
|
|
else:
|
|
self._pause_blink()
|
|
self._cursor_visible = True
|
|
|
|
@property
|
|
def cursor_screen_offset(self) -> Offset:
|
|
"""The offset of the cursor of this input in screen-space. (x, y)/(column, row)."""
|
|
x, y, _width, _height = self.content_region
|
|
scroll_x, _ = self.scroll_offset
|
|
return Offset(x + self._cursor_offset - scroll_x, y)
|
|
|
|
def _watch_value(self, value: str) -> None:
|
|
"""Update the virtual size and suggestion when the value changes."""
|
|
self.virtual_size = Size(self.content_width, 1)
|
|
self._suggestion = ""
|
|
if self.suggester and value:
|
|
self.run_worker(self.suggester._get_suggestion(self, value))
|
|
if self.styles.auto_dimensions:
|
|
self.refresh(layout=True)
|
|
|
|
validation_result = (
|
|
self.validate(value) if "changed" in self.validate_on else None
|
|
)
|
|
self.post_message(self.Changed(self, value, validation_result))
|
|
|
|
# If this is the first time the value has been updated, set the cursor position to the end
|
|
if self._initial_value:
|
|
self.cursor_position = len(self.value)
|
|
self._initial_value = False
|
|
else:
|
|
# Force a re-validation of the selection to ensure it accounts for
|
|
# the length of the new value
|
|
self.selection = self.selection
|
|
|
|
def _watch_valid_empty(self) -> None:
|
|
"""Repeat validation when valid_empty changes."""
|
|
self._watch_value(self.value)
|
|
|
|
def validate(self, value: str) -> ValidationResult | None:
|
|
"""Run all the validators associated with this Input on the supplied value.
|
|
|
|
Runs all validators, combines the result into one. If any of the validators
|
|
failed, the combined result will be a failure. If no validators are present,
|
|
None will be returned. This also sets the `-invalid` CSS class on the Input
|
|
if the validation fails, and sets the `-valid` CSS class on the Input if
|
|
the validation succeeds.
|
|
|
|
Returns:
|
|
A ValidationResult indicating whether *all* validators succeeded or not.
|
|
That is, if *any* validator fails, the result will be an unsuccessful
|
|
validation.
|
|
"""
|
|
|
|
def set_classes() -> None:
|
|
"""Set classes for valid flag."""
|
|
valid = self._valid
|
|
self.set_class(not valid, "-invalid")
|
|
self.set_class(valid, "-valid")
|
|
|
|
# If no validators are supplied, and therefore no validation occurs, we return None.
|
|
if not self.validators:
|
|
self._valid = True
|
|
set_classes()
|
|
return None
|
|
|
|
if self.valid_empty and not value:
|
|
self._valid = True
|
|
set_classes()
|
|
return None
|
|
|
|
validation_results: list[ValidationResult] = [
|
|
validator.validate(value) for validator in self.validators
|
|
]
|
|
combined_result = ValidationResult.merge(validation_results)
|
|
self._valid = combined_result.is_valid
|
|
set_classes()
|
|
|
|
return combined_result
|
|
|
|
@property
|
|
def is_valid(self) -> bool:
|
|
"""Check if the value has passed validation."""
|
|
return self._valid
|
|
|
|
def render_line(self, y: int) -> Strip:
|
|
if y != 0:
|
|
return Strip.blank(self.size.width, self.rich_style)
|
|
|
|
console = self.app.console
|
|
console_options = self.app.console_options
|
|
max_content_width = self.scrollable_content_region.width
|
|
|
|
if not self.value:
|
|
placeholder = Text(self.placeholder, justify="left", end="")
|
|
placeholder.stylize(self.get_component_rich_style("input--placeholder"))
|
|
if self.has_focus:
|
|
cursor_style = self.get_component_rich_style("input--cursor")
|
|
if self._cursor_visible:
|
|
# If the placeholder is empty, there's no characters to stylise
|
|
# to make the cursor flash, so use a single space character
|
|
if len(placeholder) == 0:
|
|
placeholder = Text(" ", end="")
|
|
placeholder.stylize(cursor_style, 0, 1)
|
|
|
|
strip = Strip(
|
|
console.render(
|
|
placeholder, console_options.update_width(max_content_width + 1)
|
|
)
|
|
)
|
|
else:
|
|
result = self._value
|
|
|
|
# Add the completion with a faded style.
|
|
value = self.value
|
|
value_length = len(value)
|
|
suggestion = self._suggestion
|
|
show_suggestion = len(suggestion) > value_length and self.has_focus
|
|
if show_suggestion:
|
|
result += Text(
|
|
suggestion[value_length:],
|
|
self.get_component_rich_style("input--suggestion"),
|
|
end="",
|
|
)
|
|
|
|
if self.has_focus:
|
|
if not self.selection.is_empty:
|
|
start, end = self.selection
|
|
start, end = sorted((start, end))
|
|
selection_style = self.get_component_rich_style("input--selection")
|
|
result.stylize_before(selection_style, start, end)
|
|
|
|
if self._cursor_visible:
|
|
cursor_style = self.get_component_rich_style("input--cursor")
|
|
cursor = self.cursor_position
|
|
if not show_suggestion and self.cursor_at_end:
|
|
result.pad_right(1)
|
|
result.stylize(cursor_style, cursor, cursor + 1)
|
|
|
|
segments = list(
|
|
console.render(result, console_options.update_width(self.content_width))
|
|
)
|
|
|
|
strip = Strip(segments)
|
|
scroll_x, _ = self.scroll_offset
|
|
strip = strip.crop(scroll_x, scroll_x + max_content_width + 1)
|
|
strip = strip.extend_cell_length(max_content_width + 1)
|
|
|
|
return strip.apply_style(self.rich_style)
|
|
|
|
@property
|
|
def _value(self) -> Text:
|
|
"""Value rendered as text."""
|
|
if self.password:
|
|
return Text("•" * len(self.value), no_wrap=True, overflow="ignore", end="")
|
|
else:
|
|
text = Text(self.value, no_wrap=True, overflow="ignore", end="")
|
|
if self.highlighter is not None:
|
|
text = self.highlighter(text)
|
|
return text
|
|
|
|
@property
|
|
def content_width(self) -> int:
|
|
"""The width of the content."""
|
|
if self.placeholder and not self.value:
|
|
return cell_len(self.placeholder)
|
|
|
|
# Extra space for cursor at the end.
|
|
return self._value.cell_len + 1
|
|
|
|
def get_content_width(self, container: Size, viewport: Size) -> int:
|
|
"""Get the widget of the content."""
|
|
return self.content_width
|
|
|
|
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
|
|
return 1
|
|
|
|
def _toggle_cursor(self) -> None:
|
|
"""Toggle visibility of cursor."""
|
|
if self.screen.is_active:
|
|
self._cursor_visible = not self._cursor_visible
|
|
else:
|
|
self._cursor_visible = True
|
|
|
|
def _on_mount(self, event: Mount) -> None:
|
|
def text_selection_started(screen: Screen) -> None:
|
|
"""Signal callback to unselect when arbitrary text selection starts."""
|
|
self.selection = Selection.cursor(self.cursor_position)
|
|
|
|
self.screen.text_selection_started_signal.subscribe(
|
|
self, text_selection_started, immediate=True
|
|
)
|
|
self._blink_timer = self.set_interval(
|
|
0.5,
|
|
self._toggle_cursor,
|
|
pause=not (self.cursor_blink and self.has_focus),
|
|
)
|
|
|
|
def _on_blur(self, event: Blur) -> None:
|
|
self._pause_blink()
|
|
validation_result = (
|
|
self.validate(self.value) if "blur" in self.validate_on else None
|
|
)
|
|
self.post_message(self.Blurred(self, self.value, validation_result))
|
|
|
|
def _on_focus(self, event: Focus) -> None:
|
|
self._restart_blink()
|
|
if self.select_on_focus and not event.from_app_focus:
|
|
self.selection = Selection(0, len(self.value))
|
|
self.app.cursor_position = self.cursor_screen_offset
|
|
self._suggestion = ""
|
|
|
|
async def _on_key(self, event: events.Key) -> None:
|
|
self._restart_blink()
|
|
|
|
if event.is_printable:
|
|
event.stop()
|
|
assert event.character is not None
|
|
selection = self.selection
|
|
if selection.is_empty:
|
|
self.insert_text_at_cursor(event.character)
|
|
else:
|
|
self.replace(event.character, *selection)
|
|
event.prevent_default()
|
|
|
|
def _on_paste(self, event: events.Paste) -> None:
|
|
if event.text:
|
|
line = event.text.splitlines()[0]
|
|
selection = self.selection
|
|
if selection.is_empty:
|
|
self.insert_text_at_cursor(line)
|
|
else:
|
|
self.replace(line, *selection)
|
|
event.stop()
|
|
|
|
def _cell_offset_to_index(self, offset: int) -> int:
|
|
"""Convert a cell offset to a character index, accounting for character width.
|
|
|
|
Args:
|
|
offset: The cell offset to convert.
|
|
|
|
Returns:
|
|
The character index corresponding to the cell offset.
|
|
"""
|
|
cell_offset = 0
|
|
_cell_size = get_character_cell_size
|
|
scroll_x, _ = self.scroll_offset
|
|
offset += scroll_x
|
|
for index, char in enumerate(self.value):
|
|
cell_width = _cell_size(char)
|
|
if cell_offset <= offset < (cell_offset + cell_width):
|
|
return index
|
|
cell_offset += cell_width
|
|
return clamp(offset, 0, len(self.value))
|
|
|
|
async def _on_mouse_down(self, event: events.MouseDown) -> None:
|
|
self._pause_blink(visible=True)
|
|
offset_x, _ = event.get_content_offset_capture(self)
|
|
self.selection = Selection.cursor(self._cell_offset_to_index(offset_x))
|
|
self._selecting = True
|
|
self.capture_mouse()
|
|
|
|
def _end_selecting(self) -> None:
|
|
"""End selecting if it is currently active."""
|
|
if self._selecting:
|
|
self._selecting = False
|
|
self.release_mouse()
|
|
self._restart_blink()
|
|
|
|
async def _on_mouse_release(self, _event: events.MouseRelease) -> None:
|
|
self._end_selecting()
|
|
|
|
async def _on_mouse_up(self, _event: events.MouseUp) -> None:
|
|
self._end_selecting()
|
|
|
|
async def _on_mouse_move(self, event: events.MouseMove) -> None:
|
|
if self._selecting:
|
|
# As we drag the mouse, we update the end position of the selection,
|
|
# keeping the start position fixed.
|
|
offset = event.get_content_offset_capture(self)
|
|
selection_start, _ = self.selection
|
|
self.selection = Selection(
|
|
selection_start, self._cell_offset_to_index(offset.x)
|
|
)
|
|
|
|
async def _on_suggestion_ready(self, event: SuggestionReady) -> None:
|
|
"""Handle suggestion messages and set the suggestion when relevant."""
|
|
if event.value == self.value:
|
|
self._suggestion = event.suggestion
|
|
|
|
def _restart_blink(self) -> None:
|
|
"""Restart the cursor blink cycle."""
|
|
self._cursor_visible = True
|
|
if self.cursor_blink and self._blink_timer:
|
|
self._blink_timer.reset()
|
|
|
|
def _pause_blink(self, visible: bool = False) -> None:
|
|
"""Hide the blinking cursor and pause the blink cycle."""
|
|
self._cursor_visible = visible
|
|
if self._blink_timer:
|
|
self._blink_timer.pause()
|
|
|
|
def insert_text_at_cursor(self, text: str) -> None:
|
|
"""Insert new text at the cursor, move the cursor to the end of the new text.
|
|
|
|
Args:
|
|
text: New text to insert.
|
|
"""
|
|
self.insert(text, self.cursor_position)
|
|
|
|
def restricted(self) -> None:
|
|
"""Called when a character has been restricted.
|
|
|
|
The default behavior is to play the system bell.
|
|
You may want to override this method if you want to disable the bell or do something else entirely.
|
|
"""
|
|
self.app.bell()
|
|
|
|
def clear(self) -> None:
|
|
"""Clear the input."""
|
|
self.value = ""
|
|
|
|
@property
|
|
def selected_text(self) -> str:
|
|
"""The text between the start and end points of the current selection."""
|
|
start, end = sorted(self.selection)
|
|
return self.value[start:end]
|
|
|
|
def action_cursor_left(self, select: bool = False) -> None:
|
|
"""Move the cursor one position to the left.
|
|
|
|
Args:
|
|
select: If `True`, select the text to the left of the cursor.
|
|
"""
|
|
start, end = self.selection
|
|
if select:
|
|
self.selection = Selection(start, end - 1)
|
|
else:
|
|
if self.selection.is_empty:
|
|
self.cursor_position -= 1
|
|
else:
|
|
self.cursor_position = min(start, end)
|
|
|
|
def action_cursor_right(self, select: bool = False) -> None:
|
|
"""Accept an auto-completion or move the cursor one position to the right.
|
|
|
|
Args:
|
|
select: If `True`, select the text to the right of the cursor.
|
|
"""
|
|
start, end = self.selection
|
|
if select:
|
|
self.selection = Selection(start, end + 1)
|
|
else:
|
|
if self.cursor_at_end and self._suggestion:
|
|
self.value = self._suggestion
|
|
self.cursor_position = len(self.value)
|
|
else:
|
|
if self.selection.is_empty:
|
|
self.cursor_position += 1
|
|
else:
|
|
self.cursor_position = max(start, end)
|
|
|
|
def select_all(self) -> None:
|
|
"""Select all of the text in the Input."""
|
|
self.selection = Selection(0, len(self.value))
|
|
self._suggestion = ""
|
|
|
|
def action_select_all(self) -> None:
|
|
"""Select all of the text in the Input."""
|
|
self.select_all()
|
|
|
|
def action_home(self, select: bool = False) -> None:
|
|
"""Move the cursor to the start of the input.
|
|
|
|
Args:
|
|
select: If `True`, select the text between the old and new cursor positions.
|
|
"""
|
|
if select:
|
|
self.selection = Selection(self.cursor_position, 0)
|
|
else:
|
|
self.cursor_position = 0
|
|
|
|
def action_end(self, select: bool = False) -> None:
|
|
"""Move the cursor to the end of the input.
|
|
|
|
Args:
|
|
select: If `True`, select the text between the old and new cursor positions.
|
|
"""
|
|
if select:
|
|
self.selection = Selection(self.cursor_position, len(self.value))
|
|
else:
|
|
self.cursor_position = len(self.value)
|
|
|
|
_WORD_START = re.compile(r"(?<=\W)\w")
|
|
|
|
def action_cursor_left_word(self, select: bool = False) -> None:
|
|
"""Move the cursor left to the start of a word.
|
|
|
|
Args:
|
|
select: If `True`, select the text between the old and new cursor positions.
|
|
"""
|
|
if self.password:
|
|
# This is a password field so don't give any hints about word
|
|
# boundaries, even during movement.
|
|
self.action_home(select)
|
|
else:
|
|
start, _ = self.selection
|
|
try:
|
|
*_, hit = re.finditer(
|
|
self._WORD_START, self.value[: self.cursor_position]
|
|
)
|
|
except ValueError:
|
|
target = 0
|
|
else:
|
|
target = hit.start()
|
|
|
|
if select:
|
|
self.selection = Selection(start, target)
|
|
else:
|
|
self.cursor_position = target
|
|
|
|
def action_cursor_right_word(self, select: bool = False) -> None:
|
|
"""Move the cursor right to the start of a word.
|
|
|
|
Args:
|
|
select: If `True`, select the text between the old and new cursor positions.
|
|
"""
|
|
if self.password:
|
|
# This is a password field so don't give any hints about word
|
|
# boundaries, even during movement.
|
|
self.action_end(select)
|
|
else:
|
|
hit = re.search(self._WORD_START, self.value[self.cursor_position :])
|
|
|
|
start, end = self.selection
|
|
if hit is None:
|
|
target = len(self.value)
|
|
else:
|
|
target = end + hit.start()
|
|
|
|
if select:
|
|
self.selection = Selection(start, target)
|
|
else:
|
|
self.cursor_position = target
|
|
|
|
def replace(self, text: str, start: int, end: int) -> None:
|
|
"""Replace the text between the start and end locations with the given text.
|
|
|
|
Args:
|
|
text: Text to replace the existing text with.
|
|
start: Start index to replace (inclusive).
|
|
end: End index to replace (inclusive).
|
|
"""
|
|
|
|
def check_allowed_value(value: str) -> bool:
|
|
"""Check if new value is restricted."""
|
|
|
|
# Check max length
|
|
if self.max_length and len(value) > self.max_length:
|
|
return False
|
|
# Check explicit restrict
|
|
if self.restrict and re.fullmatch(self.restrict, value) is None:
|
|
return False
|
|
# Check type restrict
|
|
if self.type:
|
|
type_restrict = _RESTRICT_TYPES.get(self.type, None)
|
|
if (
|
|
type_restrict is not None
|
|
and re.fullmatch(type_restrict, value) is None
|
|
):
|
|
return False
|
|
# Character is allowed
|
|
return True
|
|
|
|
value = self.value
|
|
start, end = sorted((max(0, start), min(len(value), end)))
|
|
new_value = f"{value[:start]}{text}{value[end:]}"
|
|
if check_allowed_value(new_value):
|
|
self.value = new_value
|
|
self.cursor_position = start + len(text)
|
|
else:
|
|
self.restricted()
|
|
|
|
def insert(self, text: str, index: int) -> None:
|
|
"""Insert text at the given index.
|
|
|
|
Args:
|
|
text: Text to insert.
|
|
index: Index to insert the text at (inclusive).
|
|
"""
|
|
self.replace(text, index, index)
|
|
|
|
def delete(self, start: int, end: int) -> None:
|
|
"""Delete the text between the start and end locations.
|
|
|
|
Args:
|
|
start: Start index to delete (inclusive).
|
|
end: End index to delete (inclusive).
|
|
"""
|
|
self.replace("", start, end)
|
|
|
|
def delete_selection(self) -> None:
|
|
"""Delete the current selection."""
|
|
self.delete(*self.selection)
|
|
|
|
def action_delete_right(self) -> None:
|
|
"""Delete one character at the current cursor position."""
|
|
if self.selection.is_empty:
|
|
self.delete(self.cursor_position, self.cursor_position + 1)
|
|
else:
|
|
self.delete_selection()
|
|
|
|
def action_delete_right_word(self) -> None:
|
|
"""Delete the current character and all rightward to the start of the next word."""
|
|
if not self.selection.is_empty:
|
|
self.delete_selection()
|
|
return
|
|
|
|
if self.password:
|
|
# This is a password field so don't give any hints about word
|
|
# boundaries, even during deletion.
|
|
self.action_delete_right_all()
|
|
else:
|
|
after = self.value[self.cursor_position :]
|
|
hit = re.search(self._WORD_START, after)
|
|
if hit is None:
|
|
self.action_delete_right_all()
|
|
else:
|
|
start = self.cursor_position
|
|
end = start + hit.end() - 1
|
|
self.delete(start, end)
|
|
|
|
def action_delete_right_all(self) -> None:
|
|
"""Delete the current character and all characters to the right of the cursor position."""
|
|
if self.selection.is_empty:
|
|
self.delete(self.cursor_position, len(self.value))
|
|
else:
|
|
self.delete_selection()
|
|
|
|
def action_delete_left(self) -> None:
|
|
"""Delete one character to the left of the current cursor position."""
|
|
if self.selection.is_empty:
|
|
self.delete(self.cursor_position - 1, self.cursor_position)
|
|
else:
|
|
self.delete_selection()
|
|
|
|
def action_delete_left_word(self) -> None:
|
|
"""Delete leftward of the cursor position to the start of a word."""
|
|
if not self.selection.is_empty:
|
|
self.delete_selection()
|
|
return
|
|
|
|
if self.password:
|
|
# This is a password field so don't give any hints about word
|
|
# boundaries, even during deletion.
|
|
self.action_delete_left_all()
|
|
else:
|
|
try:
|
|
*_, hit = re.finditer(
|
|
self._WORD_START, self.value[: self.cursor_position]
|
|
)
|
|
except ValueError:
|
|
target = 0
|
|
else:
|
|
target = hit.start()
|
|
|
|
self.delete(target, self.cursor_position)
|
|
|
|
def action_delete_left_all(self) -> None:
|
|
"""Delete all characters to the left of the cursor position."""
|
|
if self.selection.is_empty:
|
|
self.delete(0, self.cursor_position)
|
|
else:
|
|
self.delete_selection()
|
|
|
|
async def action_submit(self) -> None:
|
|
"""Handle a submit action.
|
|
|
|
Normally triggered by the user pressing Enter. This may also run any validators.
|
|
"""
|
|
validation_result = (
|
|
self.validate(self.value) if "submitted" in self.validate_on else None
|
|
)
|
|
self.post_message(self.Submitted(self, self.value, validation_result))
|
|
|
|
def action_cut(self) -> None:
|
|
"""Cut the current selection (copy to clipboard and remove from input)."""
|
|
self.app.copy_to_clipboard(self.selected_text)
|
|
self.delete_selection()
|
|
|
|
def action_copy(self) -> None:
|
|
"""Copy the current selection to the clipboard."""
|
|
selected_text = self.selected_text
|
|
if selected_text:
|
|
self.app.copy_to_clipboard(selected_text)
|
|
else:
|
|
raise SkipAction()
|
|
|
|
def action_paste(self) -> None:
|
|
"""Paste from the local clipboard."""
|
|
clipboard = self.app.clipboard
|
|
start, end = self.selection
|
|
self.replace(clipboard, start, end)
|