from __future__ import annotations from rich.cells import cell_len, get_character_cell_size from rich.console import Console, ConsoleOptions, RenderableType, RenderResult from rich.highlighter import Highlighter from rich.segment import Segment from rich.text import Text from .. import events from .._segment_tools import line_crop from ..binding import Binding from ..geometry import Size from ..message import Message from ..reactive import reactive from ..widget import Widget class _InputRenderable: """Render the input content.""" def __init__(self, input: Input, cursor_visible: bool) -> None: self.input = input self.cursor_visible = cursor_visible def __rich_console__( self, console: "Console", options: "ConsoleOptions" ) -> "RenderResult": input = self.input result = input._value if input._cursor_at_end: result.pad_right(1) cursor_style = input.get_component_rich_style("input--cursor") if self.cursor_visible and input.has_focus: cursor = input.cursor_position result.stylize(cursor_style, cursor, cursor + 1) width = input.content_size.width segments = list(result.render(console)) line_length = Segment.get_line_length(segments) if line_length < width: segments = Segment.adjust_line_length(segments, width) line_length = width line = line_crop( list(segments), input.view_position, input.view_position + width, line_length, ) yield from line class Input(Widget, can_focus=True): """A text input widget.""" DEFAULT_CSS = """ Input { background: $boost; color: $text; padding: 0 2; border: tall $background; width: 100%; height: 1; min-height: 1; } Input.-disabled { opacity: 0.6; } Input:focus { border: tall $accent; } Input>.input--cursor { background: $surface; color: $text; text-style: reverse; } Input>.input--placeholder { color: $text-disabled; } """ BINDINGS = [ Binding("left", "cursor_left", "cursor left", show=False), Binding("right", "cursor_right", "cursor right", show=False), Binding("backspace", "delete_left", "delete left", show=False), Binding("home", "home", "home", show=False), Binding("end", "end", "end", show=False), Binding("ctrl+d", "delete_right", "delete right", show=False), Binding("enter", "submit", "submit", show=False), ] COMPONENT_CLASSES = {"input--cursor", "input--placeholder"} cursor_blink = reactive(True) value = reactive("", layout=True, init=False) input_scroll_offset = reactive(0) cursor_position = reactive(0) view_position = reactive(0) placeholder = reactive("") complete = reactive("") width = reactive(1) _cursor_visible = reactive(True) password = reactive(False) max_size: reactive[int | None] = reactive(None) def __init__( self, value: str | None = None, placeholder: str = "", highlighter: Highlighter | None = None, password: bool = False, name: str | None = None, id: str | None = None, classes: str | None = None, ) -> 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. Default is `False`. name: Optional name for the input widget. id: Optional ID for the widget. classes: Optional initial classes for the widget. """ super().__init__(name=name, id=id, classes=classes) if value is not None: self.value = value self.placeholder = placeholder self.highlighter = highlighter self.password = password def _position_to_cell(self, position: int) -> int: """Convert an index within the value to cell position.""" cell_offset = cell_len(self.value[:position]) return cell_offset @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_end(self) -> bool: """Flag to indicate if the cursor is at the end""" return self.cursor_position >= len(self.value) def validate_cursor_position(self, cursor_position: int) -> int: return min(max(0, cursor_position), len(self.value)) def validate_view_position(self, view_position: int) -> int: width = self.content_size.width new_view_position = max(0, min(view_position, self.cursor_width - width)) return new_view_position def watch_cursor_position(self, cursor_position: int) -> None: width = self.content_size.width if width == 0: # If the input has no width the view position can't be elsewhere. self.view_position = 0 return view_start = self.view_position view_end = view_start + width cursor_offset = self._cursor_offset if cursor_offset >= view_end or cursor_offset < view_start: view_position = cursor_offset - width // 2 self.view_position = view_position else: self.view_position = self.view_position async def watch_value(self, value: str) -> None: if self.styles.auto_dimensions: self.refresh(layout=True) await self.emit(self.Changed(self, value)) @property def cursor_width(self) -> int: """The width of the input (with extra space for cursor at the end).""" if self.placeholder and not self.value: return cell_len(self.placeholder) return self._position_to_cell(len(self.value)) + 1 def render(self) -> RenderableType: if not self.value: placeholder = Text(self.placeholder, justify="left") 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(" ") placeholder.stylize(cursor_style, 0, 1) return placeholder return _InputRenderable(self, self._cursor_visible) @property def _value(self) -> Text: """Value rendered as text.""" if self.password: return Text("•" * len(self.value), no_wrap=True, overflow="ignore") else: text = Text(self.value, no_wrap=True, overflow="ignore") if self.highlighter is not None: text = self.highlighter(text) return text def get_content_width(self, container: Size, viewport: Size) -> int: return self.cursor_width def get_content_height(self, container: Size, viewport: Size, width: int) -> int: return 1 def _toggle_cursor(self) -> None: """Toggle visibility of cursor.""" self._cursor_visible = not self._cursor_visible def on_mount(self) -> None: self.blink_timer = self.set_interval( 0.5, self._toggle_cursor, pause=not (self.cursor_blink and self.has_focus), ) def on_blur(self) -> None: self.blink_timer.pause() def on_focus(self) -> None: self.cursor_position = len(self.value) if self.cursor_blink: self.blink_timer.resume() async def on_key(self, event: events.Key) -> None: self._cursor_visible = True if self.cursor_blink: self.blink_timer.reset() # Do key bindings first if await self.handle_key(event): event.prevent_default() event.stop() return elif event.is_printable: event.stop() assert event.character is not None self.insert_text_at_cursor(event.character) event.prevent_default() def on_paste(self, event: events.Paste) -> None: line = event.text.splitlines()[0] self.insert_text_at_cursor(line) event.stop() def on_click(self, event: events.Click) -> None: offset = event.get_content_offset(self) if offset is None: return event.stop() click_x = offset.x + self.view_position cell_offset = 0 _cell_size = get_character_cell_size for index, char in enumerate(self.value): if cell_offset >= click_x: self.cursor_position = index break cell_offset += _cell_size(char) else: self.cursor_position = len(self.value) 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. """ if self.cursor_position > len(self.value): self.value += text self.cursor_position = len(self.value) else: value = self.value before = value[: self.cursor_position] after = value[self.cursor_position :] self.value = f"{before}{text}{after}" self.cursor_position += len(text) def action_cursor_left(self) -> None: self.cursor_position -= 1 def action_cursor_right(self) -> None: self.cursor_position += 1 def action_home(self) -> None: self.cursor_position = 0 def action_end(self) -> None: self.cursor_position = len(self.value) def action_delete_right(self) -> None: value = self.value delete_position = self.cursor_position before = value[:delete_position] after = value[delete_position + 1 :] self.value = f"{before}{after}" self.cursor_position = delete_position def action_delete_left(self) -> None: if self.cursor_position <= 0: # Cursor at the start, so nothing to delete return if self.cursor_position == len(self.value): # Delete from end self.value = self.value[:-1] self.cursor_position = len(self.value) else: # Cursor in the middle value = self.value delete_position = self.cursor_position - 1 before = value[:delete_position] after = value[delete_position + 1 :] self.value = f"{before}{after}" self.cursor_position = delete_position async def action_submit(self) -> None: await self.emit(self.Submitted(self, self.value)) class Changed(Message, bubble=True): """Value was changed. Attributes: value: The value that the input was changed to. input: The `Input` widget that was changed. """ def __init__(self, sender: Input, value: str) -> None: super().__init__(sender) self.value: str = value self.input: Input = sender class Submitted(Message, bubble=True): """Sent when the enter key is pressed within an `Input`. Attributes: value: The value of the `Input` being submitted.. input: The `Input` widget that is being submitted. """ def __init__(self, sender: Input, value: str) -> None: super().__init__(sender) self.value: str = value self.input: Input = sender