diff --git a/poetry.lock b/poetry.lock index 1d289ffbe..9c7a5e7c0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -277,7 +277,7 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "markdown" -version = "3.3.6" +version = "3.3.7" description = "Python implementation of Markdown." category = "dev" optional = false @@ -472,7 +472,7 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.18.1" +version = "2.19.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -516,7 +516,7 @@ markdown = ">=3.2" [[package]] name = "pyparsing" -version = "3.0.8" +version = "3.0.9" description = "pyparsing module - Classes and methods to define and execute parsing grammars" category = "dev" optional = false @@ -642,7 +642,7 @@ pyyaml = "*" [[package]] name = "rich" -version = "12.3.0" +version = "12.4.1" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" category = "main" optional = false @@ -765,7 +765,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "d801e69bdd847115e92104a8cdd51ba1f207a1b7c25c4f6c9fb88434594be975" +content-hash = "37541ff4aa6aa74d76b10b8183b7e62e34b6c66c8b8f8eec7aad23e9b5793f94" [metadata.files] aiohttp = [ @@ -1052,8 +1052,8 @@ jinja2 = [ {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, ] markdown = [ - {file = "Markdown-3.3.6-py3-none-any.whl", hash = "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"}, - {file = "Markdown-3.3.6.tar.gz", hash = "sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006"}, + {file = "Markdown-3.3.7-py3-none-any.whl", hash = "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621"}, + {file = "Markdown-3.3.7.tar.gz", hash = "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874"}, ] markupsafe = [ {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, @@ -1232,8 +1232,8 @@ pluggy = [ {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] pre-commit = [ - {file = "pre_commit-2.18.1-py2.py3-none-any.whl", hash = "sha256:02226e69564ebca1a070bd1f046af866aa1c318dbc430027c50ab832ed2b73f2"}, - {file = "pre_commit-2.18.1.tar.gz", hash = "sha256:5d445ee1fa8738d506881c5d84f83c62bb5be6b2838e32207433647e8e5ebe10"}, + {file = "pre_commit-2.19.0-py2.py3-none-any.whl", hash = "sha256:10c62741aa5704faea2ad69cb550ca78082efe5697d6f04e5710c3c229afdd10"}, + {file = "pre_commit-2.19.0.tar.gz", hash = "sha256:4233a1e38621c87d9dda9808c6606d7e7ba0e087cd56d3fe03202a01d2919615"}, ] py = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, @@ -1248,8 +1248,8 @@ pymdown-extensions = [ {file = "pymdown_extensions-9.4.tar.gz", hash = "sha256:1baa22a60550f731630474cad28feb0405c8101f1a7ddc3ec0ed86ee510bcc43"}, ] pyparsing = [ - {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, - {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, ] pytest = [ {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, @@ -1316,8 +1316,8 @@ pyyaml-env-tag = [ {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, ] rich = [ - {file = "rich-12.3.0-py3-none-any.whl", hash = "sha256:0eb63013630c6ee1237e0e395d51cb23513de6b5531235e33889e8842bdf3a6f"}, - {file = "rich-12.3.0.tar.gz", hash = "sha256:7e8700cda776337036a712ff0495b04052fb5f957c7dfb8df997f88350044b64"}, + {file = "rich-12.4.1-py3-none-any.whl", hash = "sha256:d13c6c90c42e24eb7ce660db397e8c398edd58acb7f92a2a88a95572b838aaa4"}, + {file = "rich-12.4.1.tar.gz", hash = "sha256:d239001c0fb7de985e21ec9a4bb542b5150350330bbc1849f835b9cbc8923b91"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, diff --git a/pyproject.toml b/pyproject.toml index ce457926c..c0737d65f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ textual = "textual.cli.cli:run" [tool.poetry.dependencies] python = "^3.7" -rich = "^12.3.0" +rich = "^12.4.0" #rich = {git = "git@github.com:willmcgugan/rich", rev = "link-id"} click = "8.1.2" diff --git a/sandbox/input.py b/sandbox/input.py index 500cd6b5a..3dacb6cfa 100644 --- a/sandbox/input.py +++ b/sandbox/input.py @@ -1,7 +1,7 @@ from textual.app import App from textual.widget import Widget -from textual.widgets.text_input import TextInput, TextInputBase +from textual.widgets.text_input import TextInput, TextWidgetBase, TextArea def celsius_to_fahrenheit(celsius: float) -> float: @@ -19,8 +19,10 @@ class InputApp(App[str]): self.fahrenheit.focus() text_boxes = Widget(self.fahrenheit, self.celsius) self.mount(inputs=text_boxes) + self.mount(spacer=Widget()) + self.mount(text_area=TextArea()) - def handle_changed(self, event: TextInputBase.Changed) -> None: + def handle_changed(self, event: TextWidgetBase.Changed) -> None: try: value = float(event.value) except ValueError: diff --git a/sandbox/input.scss b/sandbox/input.scss index a95ffb45d..129d6c8c3 100644 --- a/sandbox/input.scss +++ b/sandbox/input.scss @@ -1,11 +1,17 @@ App { - layout: dock; - docks: top=top bot=bottom; background: $secondary; } +#spacer { + height: 1; + background: $primary-darken-2; + dock: top; +} + Screen { + layout: dock; + docks: top=top bottom=bottom; background: $secondary; } @@ -29,3 +35,7 @@ Screen { background: $secondary; height: 20; } + +#text_area { + dock: bottom; +} diff --git a/src/textual/keys.py b/src/textual/keys.py index 688bcab1e..d59f46276 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -202,7 +202,7 @@ class Keys(str, Enum): @classmethod def values(cls): """Returns a set of all the enum values.""" - return set(cls._value2member_map_.keys()) + return set(cls._value2member_map_.values()) @dataclass diff --git a/src/textual/widgets/text_input.py b/src/textual/widgets/text_input.py index dd274dbe6..8cfcbb975 100644 --- a/src/textual/widgets/text_input.py +++ b/src/textual/widgets/text_input.py @@ -1,10 +1,12 @@ from __future__ import annotations from rich.console import RenderableType +from rich.padding import Padding from rich.text import Text from textual import events from textual._types import MessageTarget +from textual.app import ComposeResult from textual.geometry import Size from textual.keys import Keys from textual.message import Message @@ -12,8 +14,8 @@ from textual.reactive import Reactive from textual.widget import Widget -class TextInputBase(Widget): - ALLOW_PROPAGATE = {} +class TextWidgetBase(Widget): + STOP_PROPAGATE = set() current_text = Reactive("", layout=True) cursor_index = Reactive(0) @@ -24,7 +26,7 @@ class TextInputBase(Widget): if key == "\x1b": return - changed = False + repaint = False if key == "ctrl+h" and self.cursor_index != 0: new_text = ( self.current_text[: self.cursor_index - 1] @@ -32,14 +34,14 @@ class TextInputBase(Widget): ) self.current_text = new_text self.cursor_index = max(0, self.cursor_index - 1) - changed = True + repaint = True elif key == "ctrl+d" and self.cursor_index != len(self.current_text): new_text = ( self.current_text[: self.cursor_index] + self.current_text[self.cursor_index + 1 :] ) self.current_text = new_text - changed = True + repaint = True elif key == "left": self.cursor_index = max(0, self.cursor_index - 1) elif key == "right": @@ -49,45 +51,60 @@ class TextInputBase(Widget): elif key == "end": self.cursor_index = len(self.current_text) elif key not in Keys.values(): - new_text = ( - self.current_text[: self.cursor_index] - + key - + self.current_text[self.cursor_index :] - ) - self.current_text = new_text - self.cursor_index = min(len(self.current_text), self.cursor_index + 1) - changed = True + self.insert_at_cursor(key) + repaint = True - if changed: + if repaint: self.post_message_no_wait(self.Changed(self, value=self.current_text)) + self.refresh(layout=True) -class TextInput(TextInputBase, can_focus=True): + def insert_at_cursor(self, text: str) -> None: + new_text = ( + self.current_text[: self.cursor_index] + + text + + self.current_text[self.cursor_index :] + ) + self.current_text = new_text + self.cursor_index = min(len(self.current_text), self.cursor_index + 1) - ALLOW_PROPAGATE = {"tab", "shift+tab"} + def _apply_cursor_to_text(self, display_text: Text, index: int): + # 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[index].plain == "\n" + if at_end_of_text or at_end_of_line: + display_text = Text.assemble( + display_text[:index], + "█", + display_text[index:], + ) + else: + display_text.stylize( + "reverse", + start=index, + end=index + 1, + ) + return display_text + class Changed(Message, bubble=True): + 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(Widget): CSS = """ - TextInput { - width: auto; - background: $primary; - height: 3; - padding: 0 1; - content-align: left middle; - } - - TextInput:hover { - background: $primary-darken-1; - } - - TextInput:focus { - background: $primary-darken-2; - border: solid $primary-lighten-1; - padding: 0; - } - - App.-show-focus TextInput:focus { - tint: $accent 20%; - } + TextInput { + overflow: hidden hidden; + background: $primary-darken-1; + scrollbar-color: $primary-darken-2; + } """ def __init__( @@ -101,8 +118,68 @@ class TextInput(TextInputBase, can_focus=True): ): super().__init__(name=name, id=id, classes=classes) self.placeholder = placeholder + self.initial = initial self.current_text = initial if initial else "" + def compose(self) -> ComposeResult: + yield TextInputChild( + placeholder=self.placeholder, initial=self.initial, wrapper=self + ) + + 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 TextInputChild(TextWidgetBase, can_focus=True): + CSS = """ + TextInputChild { + width: auto; + background: $primary; + height: 3; + padding: 0 1; + content-align: left middle; + background: $primary-darken-1; + } + + TextInputChild:hover { + background: $primary-darken-1; + } + + TextInputChild:focus { + background: $primary-darken-2; + border: hkey $primary-lighten-1; + padding: 0; + } + + App.-show-focus TextInputChild:focus { + tint: $accent 20%; + } + """ + + def __init__( + self, + *, + wrapper: Widget, + placeholder: str = "", + initial: str = "", + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ): + super().__init__(name=name, id=id, classes=classes) + self.placeholder = placeholder + self.current_text = initial if initial else "" + self.wrapper = wrapper + def get_content_width(self, container_size: Size, viewport_size: Size) -> int: return ( max(len(self.current_text), len(self.placeholder)) @@ -127,48 +204,62 @@ class TextInput(TextInputBase, can_focus=True): display_text = self._apply_cursor_to_text(display_text, 0) return display_text - def _apply_cursor_to_text(self, display_text: Text, index: int): - # Either write a cursor character or apply reverse style to cursor location - if index == len(display_text): - display_text += "█" - else: - display_text.stylize( - "reverse", - start=index, - end=index + 1, - ) - return display_text - - def on_focus(self, event: events.Focus) -> None: - self.refresh(layout=True) - def on_key(self, event: events.Key) -> None: key = event.key - if key not in self.ALLOW_PROPAGATE: + if key in self.STOP_PROPAGATE: event.stop() if key == "enter" and self.current_text: self.post_message_no_wait(TextInput.Submitted(self, self.current_text)) + elif key == "ctrl+h": + self.wrapper.scroll_left() + elif key == "home": + self.wrapper.scroll_home() + elif key == "end": + print(self.wrapper.max_scroll_x) + self.wrapper.scroll_to(x=self.wrapper.max_scroll_x) + elif key not in Keys.values(): + self.wrapper.scroll_to(x=self.wrapper.scroll_x + 1) - class Changed(Message, bubble=True): - 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 TextArea(Widget): + CSS = """ + TextArea { overflow: auto auto; height: 5; background: $primary-darken-1; } +""" - 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. + def compose(self) -> ComposeResult: + yield TextAreaChild() - Args: - sender (MessageTarget): Sender of the message - value (str): The value in the TextInput - """ - super().__init__(sender) - self.value = value + +class TextAreaChild(TextWidgetBase, can_focus=True): + # TODO: Not nearly ready for prime-time, but it exists to help + # model the superclass. + 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.current_text, no_wrap=True) + if show_cursor: + display_text = self._apply_cursor_to_text(display_text, self.cursor_index) + return Padding(display_text, pad=1) + + def get_content_height( + self, container_size: Size, viewport_size: Size, width: int + ) -> int: + return self.current_text.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.insert_at_cursor("\n") + elif event.key == "tab": + self.insert_at_cursor("\t") + elif event.key == "\x1b": + self.app.focused = None + + def on_focus(self, event: events.Focus) -> None: + self.refresh(layout=True)