diff --git a/docs/examples/events/dictionary.py b/docs/examples/events/dictionary.py index 392070624..24544c39e 100644 --- a/docs/examples/events/dictionary.py +++ b/docs/examples/events/dictionary.py @@ -8,7 +8,7 @@ except ImportError: from rich.json import JSON from textual.app import App, ComposeResult from textual.containers import Vertical -from textual.widgets import Static, TextInput +from textual.widgets import Static, Input class DictionaryApp(App): @@ -17,10 +17,10 @@ class DictionaryApp(App): CSS_PATH = "dictionary.css" def compose(self) -> ComposeResult: - yield TextInput(placeholder="Search for a word") + yield Input(placeholder="Search for a word") yield Vertical(Static(id="results"), id="results-container") - async def on_text_input_changed(self, message: TextInput.Changed) -> None: + async def on_input_changed(self, message: Input.Changed) -> None: """A coroutine to handle a text changed message.""" if message.value: # Look up the word in the background @@ -35,7 +35,7 @@ class DictionaryApp(App): async with httpx.AsyncClient() as client: results = (await client.get(url)).text - if word == self.query_one(TextInput).value: + if word == self.query_one(Input).value: self.query_one("#results", Static).update(JSON(results)) diff --git a/examples/dictionary.css b/examples/dictionary.css index cf40b9cc1..8850249c4 100644 --- a/examples/dictionary.css +++ b/examples/dictionary.css @@ -2,30 +2,25 @@ Screen { background: $panel; } -TextInput { +Input { dock: top; - border: tall $background; - width: 100%; - height: 1; - padding: 0 1; - margin: 1 1 0 1; - background: $boost; -} - -TextInput:focus { - border: tall $accent; + margin: 1 0; } #results { width: auto; min-height: 100%; - padding: 0 1; - + padding: 0 1; } #results-container { background: $background 50%; - margin: 1 2; + margin: 0; height: 100%; overflow: hidden auto; + border: tall $background; +} + +#results-container:focus { + border: tall $accent; } diff --git a/examples/dictionary.py b/examples/dictionary.py index 4fa7d22bc..80936ac01 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -11,8 +11,8 @@ except ImportError: from rich.markdown import Markdown from textual.app import App, ComposeResult -from textual.containers import Vertical -from textual.widgets import Static, TextInput +from textual.containers import Content +from textual.widgets import Static, Input class DictionaryApp(App): @@ -21,10 +21,10 @@ class DictionaryApp(App): CSS_PATH = "dictionary.css" def compose(self) -> ComposeResult: - yield TextInput(placeholder="Search for a word") - yield Vertical(Static(id="results"), id="results-container") + yield Input(placeholder="Search for a word") + yield Content(Static(id="results"), id="results-container") - async def on_text_input_changed(self, message: TextInput.Changed) -> None: + async def on_input_changed(self, message: Input.Changed) -> None: """A coroutine to handle a text changed message.""" if message.value: # Look up the word in the background @@ -39,22 +39,26 @@ class DictionaryApp(App): async with httpx.AsyncClient() as client: results = (await client.get(url)).json() - if word == self.query_one(TextInput).value: + if word == self.query_one(Input).value: markdown = self.make_word_markdown(results) self.query_one("#results", Static).update(Markdown(markdown)) - def make_word_markdown(self, results: list[Any]) -> str: + def make_word_markdown(self, results: object) -> str: """Convert the results in to markdown.""" lines = [] - for result in results: - lines.append(f"# {result['word']}") - lines.append("") - for meaning in result.get("meanings", []): - lines.append(f"_{meaning['partOfSpeech']}_") + if isinstance(results, dict): + lines.append(f"# {results['title']}") + lines.append(results["message"]) + elif isinstance(results, list): + for result in results: + lines.append(f"# {result['word']}") lines.append("") - for definition in meaning.get("definitions", []): - lines.append(f" - {definition['definition']}") - lines.append("---") + for meaning in result.get("meanings", []): + lines.append(f"_{meaning['partOfSpeech']}_") + lines.append("") + for definition in meaning.get("definitions", []): + lines.append(f" - {definition['definition']}") + lines.append("---") return "\n".join(lines) diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py index 3fadf7f35..795164b6c 100644 --- a/src/textual/cli/previews/easing.py +++ b/src/textual/cli/previews/easing.py @@ -8,8 +8,7 @@ from textual.containers import Container, Horizontal, Vertical from textual.reactive import Reactive from textual.scrollbar import ScrollBarRender from textual.widget import Widget -from textual.widgets import Button, Footer, Static, TextInput -from textual.widgets._text_input import TextWidgetBase +from textual.widgets import Button, Footer, Static, Input VIRTUAL_SIZE = 100 WINDOW_SIZE = 10 @@ -45,7 +44,6 @@ class Bar(Widget): self.set_class(running, "-active") def render(self) -> RenderableType: - return ScrollBarRender( virtual_size=VIRTUAL_SIZE, window_size=WINDOW_SIZE, @@ -67,9 +65,7 @@ class EasingApp(App): def compose(self) -> ComposeResult: self.animated_bar = Bar() self.animated_bar.position = START_POSITION - duration_input = TextInput( - placeholder="Duration", initial="1.0", id="duration-input" - ) + duration_input = Input("1.0", placeholder="Duration", id="duration-input") self.opacity_widget = Static( f"[b]Welcome to Textual![/]\n\n{TEXT}", id="opacity-widget" @@ -109,7 +105,7 @@ class EasingApp(App): self.animated_bar.position = value self.opacity_widget.styles.opacity = 1 - value / END_POSITION - def on_text_input_changed(self, event: TextInput.Changed): + def on_input_changed(self, event: Input.Changed): if event.sender.id == "duration-input": new_duration = _try_float(event.value) if new_duration is not None: diff --git a/src/textual/containers.py b/src/textual/containers.py index 331d040df..231e220b0 100644 --- a/src/textual/containers.py +++ b/src/textual/containers.py @@ -42,3 +42,14 @@ class Grid(Widget): layout: grid; } """ + + +class Content(Widget, can_focus=True, can_focus_children=False): + """A container for content such as text.""" + + DEFAULT_CSS = """ + Vertical { + layout: vertical; + overflow-y: auto; + } + """ diff --git a/src/textual/events.py b/src/textual/events.py index f1cb1232f..d0cb8c24a 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -354,6 +354,19 @@ class MouseEvent(InputEvent, bubble=True): def style(self, style: Style) -> None: self._style = style + def get_content_offset(self, widget: Widget) -> Offset | None: + """Get offset within a widget's content area, or None if offset is not in content (i.e. padding or border). + + Args: + widget (Widget): Widget receiving the event. + + Returns: + Offset | None: An offset where the origin is at the top left of the content area. + """ + if self.offset not in widget.content_region: + return None + return self.offset - widget.gutter.top_left + def _apply_offset(self, x: int, y: int) -> MouseEvent: return self.__class__( self.sender, diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index fc057be7f..634b66785 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -19,7 +19,6 @@ __all__ = [ "Placeholder", "Pretty", "Static", - "TextInput", "Input", "TextLog", "TreeControl", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index ef43fff3a..922bfb479 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -8,7 +8,6 @@ from ._placeholder import Placeholder as Placeholder from ._pretty import Pretty as Pretty from ._static import Static as Static from ._input import Input as Input -from ._text_input import TextInput as TextInput from ._text_log import TextLog as TextLog from ._tree_control import TreeControl as TreeControl from ._welcome import Welcome as Welcome diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index d0186ace7..7bc4d3a8c 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -185,7 +185,7 @@ class Button(Static, can_focus=True): if label is None: label = self.css_identifier_styled - self.label = label + self.label = self.validate_label(label) self.disabled = disabled if disabled: diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index b6b97db61..bf3db3725 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -1,21 +1,23 @@ from __future__ import annotations -from rich.console import Console, ConsoleOptions, RenderResult, RenderableType -from rich.cells import cell_len +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 .._segment_tools import line_crop from ..message import Message, MessageTarget 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 @@ -57,9 +59,12 @@ class Input(Widget, can_focus=True): color: $text; padding: 0 2; border: tall $background; - width: auto; + width: 100%; height: 1; } + Input.-disabled { + opacity: 0.6; + } Input:focus { border: tall $accent; } @@ -77,6 +82,9 @@ class Input(Widget, can_focus=True): Binding("left", "cursor_left", "cursor left"), Binding("right", "cursor_right", "cursor right"), Binding("backspace", "delete_left", "delete left"), + Binding("home", "home", "Home"), + Binding("end", "end", "Home"), + Binding("ctrl+d", "delete_right", "Delete"), ] COMPONENT_CLASSES = {"input--cursor", "input--placeholder"} @@ -87,10 +95,8 @@ class Input(Widget, can_focus=True): 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) @@ -110,11 +116,13 @@ class Input(Widget, can_focus=True): self.highlighter = highlighter 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: + """Get the cell offset of the cursor.""" offset = self._position_to_cell(self.cursor_position) if self._cursor_at_end: offset += 1 @@ -133,7 +141,7 @@ class Input(Widget, can_focus=True): new_view_position = max(0, min(view_position, self.cursor_width - width)) return new_view_position - def watch_cursor_position(self, old_position: int, new_position: int) -> None: + def watch_cursor_position(self, cursor_position: int) -> None: width = self.content_size.width view_start = self.view_position view_end = view_start + width @@ -145,10 +153,13 @@ class Input(Widget, can_focus=True): else: self.view_position = self.view_position + async def watch_value(self, value: str) -> None: + await self.emit(self.Changed(self, value)) + @property def cursor_width(self) -> int: + """Get 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 @@ -171,7 +182,7 @@ class Input(Widget, can_focus=True): super().__init__(sender) self.value = value - class Updated(Message, bubble=True): + class Submitted(Message, bubble=True): """Value was updated via enter key or blur.""" def __init__(self, sender: MessageTarget, value: str) -> None: @@ -180,6 +191,7 @@ class Input(Widget, can_focus=True): @property def _value(self) -> Text: + """Value rendered as text.""" if self.password: return Text("•" * len(self.value), no_wrap=True, overflow="ignore") else: @@ -188,23 +200,20 @@ class Input(Widget, can_focus=True): text = self.highlighter(text) return text - @property - def _complete(self) -> Text: - return Text(self.complete, no_wrap=True, overflow="ignore") - 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: + 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, + self._toggle_cursor, pause=not (self.cursor_blink and self.has_focus), ) @@ -220,14 +229,37 @@ class Input(Widget, can_focus=True): self._cursor_visible = True if self.cursor_blink: self.blink_timer.reset() - event.prevent_default() # Do key bindings first if await self.handle_key(event): + event.stop() + elif event.key == "tab": return - - if event.char is not None: + elif event.is_printable: + event.stop() + assert event.char is not None self.insert_text_at_cursor(event.char) + event.prevent_default() + + def on_paste(self, event: events.Paste) -> None: + line = event.text.splitlines()[0] + self.insert_text_at_cursor(line) + + 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: if self.cursor_position > len(self.value): @@ -246,6 +278,20 @@ class Input(Widget, can_focus=True): 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 == len(self.value): self.value = self.value[:-1] @@ -257,3 +303,6 @@ class Input(Widget, can_focus=True): 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)) diff --git a/src/textual/widgets/_text_input.py b/src/textual/widgets/_text_input.py deleted file mode 100644 index 2d7851d2b..000000000 --- a/src/textual/widgets/_text_input.py +++ /dev/null @@ -1,475 +0,0 @@ -from __future__ import annotations - -from typing import Callable - -from rich.cells import cell_len -from rich.console import RenderableType -from rich.padding import Padding -from rich.text import Text - -from textual import events, _clock -from textual._text_backend import TextEditorBackend -from textual.timer import Timer -from textual._types import MessageTarget -from textual.app import ComposeResult -from textual.geometry import Size, clamp -from textual.message import Message -from textual.reactive import Reactive -from textual.widget import Widget - - -class TextWidgetBase(Widget): - """Base class for Widgets which support text input""" - - STOP_PROPAGATE: set[str] = set() - """Set of keybinds which will not be propagated to parent widgets""" - - cursor_blink_enabled = Reactive(False) - cursor_blink_period = Reactive(0.6) - - def __init__( - self, - name: str | None = None, - id: str | None = None, - classes: str | None = None, - ): - super().__init__(name=name, id=id, classes=classes) - self._editor = TextEditorBackend() - - def on_key(self, event: events.Key) -> None: - key = event.key - if key == "escape": - return - changed = False - if event.char is not None and event.is_printable: - changed = self._editor.insert(event.char) - elif key == "backspace": - changed = self._editor.delete_back() - elif key == "ctrl+d": - changed = self._editor.delete_forward() - elif key == "left": - self._editor.cursor_left() - elif key == "right": - self._editor.cursor_right() - elif key == "home" or key == "ctrl+a": - self._editor.cursor_text_start() - elif key == "end" or key == "ctrl+e": - self._editor.cursor_text_end() - - self.refresh(layout=True) - - if changed: - self.post_message_no_wait(self.Changed(self, value=self._editor.content)) - - def _apply_cursor_to_text(self, display_text: Text, index: int) -> Text: - if index < 0: - return display_text - - # Either write a cursor character or apply reverse style to cursor location - at_end_of_text = index == len(display_text) - at_end_of_line = index < len(display_text) and display_text.plain[index] == "\n" - - if at_end_of_text or at_end_of_line: - display_text = Text.assemble( - display_text[:index], - "█", - display_text[index:], - overflow="ignore", - no_wrap=True, - ) - else: - display_text.stylize( - "reverse", - start=index, - end=index + 1, - ) - return display_text - - class Changed(Message, bubble=True): - namespace = "text_input" - - def __init__(self, sender: MessageTarget, value: str) -> None: - """Message posted when the user changes the value in a TextInput - - Args: - sender (MessageTarget): Sender of the message - value (str): The value in the TextInput - """ - super().__init__(sender) - self.value = value - - -class TextInput(TextWidgetBase, can_focus=True): - """Widget for inputting text - - Args: - placeholder (str): The text that will be displayed when there's no content in the TextInput. - Defaults to an empty string. - initial (str): The initial value. Defaults to an empty string. - autocompleter (Callable[[str], str | None): Function which returns autocomplete suggestion - which will be displayed within the widget any time the content changes. The autocomplete - suggestion will be displayed as dim text similar to suggestion text in the zsh or fish shells. - """ - - DEFAULT_CSS = """ - TextInput { - width: auto; - height: 3; - padding: 1; - background: $surface; - content-align: left middle; - color: $text; - } - TextInput .text-input--placeholder { - color: $text-muted; - } - """ - - COMPONENT_CLASSES = { - "text-input--placeholder", - } - - def __init__( - self, - *, - placeholder: str = "", - initial: str = "", - autocompleter: Callable[[str], str | None] | None = None, - name: str | None = None, - id: str | None = None, - classes: str | None = None, - ): - super().__init__(name=name, id=id, classes=classes) - self.placeholder = placeholder - self._editor = TextEditorBackend(initial, 0) - self.visible_range: tuple[int, int] = (0, 0) - self.autocompleter = autocompleter - self._suggestion_suffix = "" - - self._cursor_blink_visible = True - self._cursor_blink_timer: Timer | None = None - self._last_keypress_time: float = 0.0 - if self.cursor_blink_enabled: - self._last_keypress_time = _clock.get_time_no_wait() - self._cursor_blink_timer = self.set_interval( - self.cursor_blink_period, self._toggle_cursor_visible - ) - - @property - def value(self) -> str: - """Get the value from the text input widget as a string - - Returns: - str: The value in the text input widget - """ - return self._editor.content - - @value.setter - def value(self, value: str) -> None: - """Update the value in the text input widget and move the cursor to the end of - the new value.""" - self._editor.set_content(value) - self._editor.cursor_text_end() - self.refresh() - - def get_content_width(self, container: Size, viewport: Size) -> int: - # TODO: Why does this need +2 ? - return min(cell_len(self._editor.content) + 2, container.width) - - def _on_resize(self, event: events.Resize) -> None: - # Ensure the cursor remains visible when the widget is resized - self._reset_visible_range() - - async def _on_click(self, event: events.Click) -> None: - """When the user clicks on the text input, the cursor moves to the - character that was clicked on. Double-width characters makes this more - difficult.""" - - # If they've clicked outwith the content region (e.g. on padding), do nothing. - if not self.content_region.contains_point((event.screen_x, event.screen_y)): - return - - self._cursor_blink_visible = True - start_index, end_index = self.visible_range - - click_x = event.screen_x - self.content_region.x - new_cursor_index = start_index + click_x - - # Convert click offset to cursor index accounting for varying cell lengths - cell_len_accumulated = 0 - for index, character in enumerate( - self._editor.get_range(start_index, end_index) - ): - cell_len_accumulated += cell_len(character) - if cell_len_accumulated > click_x: - new_cursor_index = start_index + index - break - - new_cursor_index = clamp(new_cursor_index, 0, len(self._editor.content)) - self._editor.cursor_index = new_cursor_index - self.refresh() - - def _on_paste(self, event: events.Paste) -> None: - """Handle Paste event by stripping newlines from the text, and inserting - the text at the cursor position, sliding the visible window if required.""" - text = "".join(event.text.splitlines()) - width_behind_cursor = self._visible_content_to_cursor_cell_len - self._editor.insert(text) - paste_cell_len = cell_len(text) - available_width = self.content_region.width - - # By inserting the pasted text, how far beyond the bounds of the text input - # will we move the cursor? We need to slide the visible window along by that. - scroll_amount = paste_cell_len + width_behind_cursor - available_width + 1 - - if scroll_amount > 0: - self._slide_window(scroll_amount) - - self.refresh() - - def _slide_window(self, amount: int) -> None: - """Slide the visible window left or right by `amount`. Negative integers move - the window left, and positive integers move the window right.""" - start, end = self.visible_range - self.visible_range = start + amount, end + amount - - def _reset_visible_range(self): - """Reset our window into the editor content. Used when the widget is resized.""" - available_width = self.content_region.width - - # Adjust the window end such that the cursor is just off of it - new_visible_range_end = max(self._editor.cursor_index + 2, available_width) - # The visible window extends back by the width of the content region - new_visible_range_start = new_visible_range_end - available_width - - # Check the cell length of the newly visible content and adjust window to accommodate - new_range = self._editor.get_range( - new_visible_range_start, new_visible_range_end - ) - new_range_cell_len = cell_len(new_range) - additional_shift_required = max(0, new_range_cell_len - available_width) - - self.visible_range = ( - new_visible_range_start + additional_shift_required, - new_visible_range_end + additional_shift_required, - ) - - self.refresh() - - def render(self) -> RenderableType: - # First render: Cursor at start of text, visible range goes from cursor to content region width - if not self.visible_range: - self.visible_range = (self._editor.cursor_index, self.content_region.width) - - # We only show the cursor if the widget has focus - show_cursor = self.has_focus and self._cursor_blink_visible - if self._editor.content: - start, end = self.visible_range - visible_text = self._editor.get_range(start, end) - display_text = Text(visible_text, no_wrap=True, overflow="ignore") - - if self._suggestion_suffix: - display_text.append(self._suggestion_suffix, "dim") - - if show_cursor: - display_text = self._apply_cursor_to_text( - display_text, self._editor.cursor_index - start - ) - return display_text - else: - # The user has not entered text - show the placeholder - display_text = Text( - self.placeholder, - self.get_component_rich_style("text-input--placeholder"), - no_wrap=True, - overflow="ignore", - ) - if show_cursor: - display_text = self._apply_cursor_to_text(display_text, 0) - return display_text - - @property - def _visible_content_to_cursor_cell_len(self) -> int: - """The cell width of the visible content up to the cursor cell""" - start, _ = self.visible_range - visible_content_to_cursor = self._editor.get_range( - start, self._editor.cursor_index + 1 - ) - return cell_len(visible_content_to_cursor) - - @property - def _cursor_at_right_edge(self) -> bool: - """True if the cursor is at the right edge of the content area""" - return self._visible_content_to_cursor_cell_len == self.content_region.width - - def _on_key(self, event: events.Key) -> None: - key = event.key - if key in self.STOP_PROPAGATE: - event.stop() - - self._last_keypress_time = _clock.get_time_no_wait() - if self._cursor_blink_timer: - self._cursor_blink_visible = True - - # Cursor location and the *codepoint* range of our view into the content - start, end = self.visible_range - cursor_index = self._editor.cursor_index - - # We can scroll if the cell width of the content is greater than the content region - available_width = self.content_region.width - scrollable = cell_len(self._editor.content) >= available_width - - # Check what content is visible from the editor, and how wide that content is - visible_content_to_cursor_cell_len = self._visible_content_to_cursor_cell_len - - cursor_at_end = self._cursor_at_right_edge - key_cell_len = cell_len(key) - if event.is_printable: - # Check if we'll need to scroll to accommodate the new cell width after insertion. - if visible_content_to_cursor_cell_len + key_cell_len >= available_width: - self._slide_window(key_cell_len) - self._update_suggestion(event) - elif key == "enter" and self._editor.content: - self.post_message_no_wait(TextInput.Submitted(self, self._editor.content)) - elif key == "right": - if ( - cursor_at_end - or visible_content_to_cursor_cell_len == available_width - 1 - and cell_len(self._editor.query_cursor_right() or "") == 2 - ): - if scrollable: - character_to_right = self._editor.query_cursor_right() - if character_to_right is not None: - cell_width_character_to_right = cell_len(character_to_right) - window_shift_amount = cell_width_character_to_right - else: - window_shift_amount = 1 - self._slide_window(window_shift_amount) - if self._suggestion_suffix and self._editor.cursor_at_end: - self._editor.insert(self._suggestion_suffix) - self._suggestion_suffix = "" - self._reset_visible_range() - elif key == "left": - if cursor_index == start: - if scrollable and self._editor.query_cursor_left(): - self.visible_range = ( - cursor_index - 1, - cursor_index + available_width - 1, - ) - else: - self.app.bell() - elif key == "backspace": - if cursor_index == start and self._editor.query_cursor_left(): - self._slide_window(-1) - self._update_suggestion(event) - elif key == "ctrl+d": - self._update_suggestion(event) - elif key == "home" or key == "ctrl+a": - self.visible_range = (0, available_width) - elif key == "end" or key == "ctrl+e": - num_codepoints = len(self.value) - final_visible_codepoints = self._editor.get_range( - num_codepoints - available_width + 1, - max(num_codepoints, available_width) + 1, - ) - cell_len_final_visible = cell_len(final_visible_codepoints) - - # Additional shift to ensure there's space for double width character - additional_shift_required = ( - max(0, cell_len_final_visible - available_width) + 2 - ) - if scrollable: - self.visible_range = ( - num_codepoints - available_width + additional_shift_required, - max(available_width, num_codepoints) + additional_shift_required, - ) - else: - self.visible_range = (0, available_width) - - # We need to clamp the visible range to ensure we don't use negative indexing - start, end = self.visible_range - self.visible_range = (max(0, start), end) - - def _update_suggestion(self, event: events.Key) -> None: - """Run the autocompleter function, updating the suggestion if necessary""" - if self.autocompleter is not None: - # TODO: We shouldn't be doing the stuff below here, maybe we need to add - # a method to the editor to query an edit operation? - event.prevent_default() - super().on_key(event) - if self.value: - full_suggestion = self.autocompleter(self.value) - if full_suggestion: - suffix = full_suggestion[len(self.value) :] - self._suggestion_suffix = suffix - else: - self._suggestion_suffix = None - else: - self._suggestion_suffix = None - - def _toggle_cursor_visible(self): - """Manages the blinking of the cursor - ensuring blinking only starts when the - user hasn't pressed a key in some time""" - if ( - _clock.get_time_no_wait() - self._last_keypress_time - > self.cursor_blink_period - ): - self._cursor_blink_visible = not self._cursor_blink_visible - self.refresh() - - class Submitted(Message, bubble=True): - def __init__(self, sender: MessageTarget, value: str) -> None: - """Message posted when the user presses the 'enter' key while - focused on a TextInput widget. - - Args: - sender (MessageTarget): Sender of the message - value (str): The value in the TextInput - """ - super().__init__(sender) - self.value = value - - -class TextArea(Widget): - DEFAULT_CSS = """ - TextArea { overflow: auto auto; height: 5; background: $primary-darken-1; } -""" - - def compose(self) -> ComposeResult: - yield TextAreaChild() - - -class TextAreaChild(TextWidgetBase, can_focus=True): - # TODO: Not nearly ready for prime-time, but it exists to help - # model the superclass. - DEFAULT_CSS = "TextAreaChild { height: auto; background: $primary-darken-1; }" - STOP_PROPAGATE = {"tab", "shift+tab"} - - def render(self) -> RenderableType: - # We only show the cursor if the widget has focus - show_cursor = self.has_focus - display_text = Text(self._editor.content, no_wrap=True) - if show_cursor: - display_text = self._apply_cursor_to_text( - display_text, self._editor.cursor_index - ) - return Padding(display_text, pad=1) - - def get_content_height( - self, container_size: Size, viewport_size: Size, width: int - ) -> int: - return self._editor.content.count("\n") + 1 + 2 - - def _on_key(self, event: events.Key) -> None: - if event.key in self.STOP_PROPAGATE: - event.stop() - - if event.key == "enter": - self._editor.insert("\n") - elif event.key == "tab": - self._editor.insert("\t") - elif event.key == "escape": - self.app.focused = None - - def _on_focus(self, event: events.Focus) -> None: - self.refresh(layout=True)