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)