Simple multiline text widget to help model text base class

This commit is contained in:
Darren Burns
2022-05-12 11:15:41 +01:00
parent 75085a9f4f
commit c34973ce80
6 changed files with 194 additions and 91 deletions

26
poetry.lock generated
View File

@@ -277,7 +277,7 @@ i18n = ["Babel (>=2.7)"]
[[package]] [[package]]
name = "markdown" name = "markdown"
version = "3.3.6" version = "3.3.7"
description = "Python implementation of Markdown." description = "Python implementation of Markdown."
category = "dev" category = "dev"
optional = false optional = false
@@ -472,7 +472,7 @@ testing = ["pytest", "pytest-benchmark"]
[[package]] [[package]]
name = "pre-commit" name = "pre-commit"
version = "2.18.1" version = "2.19.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks." description = "A framework for managing and maintaining multi-language pre-commit hooks."
category = "dev" category = "dev"
optional = false optional = false
@@ -516,7 +516,7 @@ markdown = ">=3.2"
[[package]] [[package]]
name = "pyparsing" name = "pyparsing"
version = "3.0.8" version = "3.0.9"
description = "pyparsing module - Classes and methods to define and execute parsing grammars" description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "dev" category = "dev"
optional = false optional = false
@@ -642,7 +642,7 @@ pyyaml = "*"
[[package]] [[package]]
name = "rich" 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" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
category = "main" category = "main"
optional = false optional = false
@@ -765,7 +765,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.7" python-versions = "^3.7"
content-hash = "d801e69bdd847115e92104a8cdd51ba1f207a1b7c25c4f6c9fb88434594be975" content-hash = "37541ff4aa6aa74d76b10b8183b7e62e34b6c66c8b8f8eec7aad23e9b5793f94"
[metadata.files] [metadata.files]
aiohttp = [ aiohttp = [
@@ -1052,8 +1052,8 @@ jinja2 = [
{file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
] ]
markdown = [ markdown = [
{file = "Markdown-3.3.6-py3-none-any.whl", hash = "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"}, {file = "Markdown-3.3.7-py3-none-any.whl", hash = "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621"},
{file = "Markdown-3.3.6.tar.gz", hash = "sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006"}, {file = "Markdown-3.3.7.tar.gz", hash = "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874"},
] ]
markupsafe = [ markupsafe = [
{file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, {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"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
] ]
pre-commit = [ pre-commit = [
{file = "pre_commit-2.18.1-py2.py3-none-any.whl", hash = "sha256:02226e69564ebca1a070bd1f046af866aa1c318dbc430027c50ab832ed2b73f2"}, {file = "pre_commit-2.19.0-py2.py3-none-any.whl", hash = "sha256:10c62741aa5704faea2ad69cb550ca78082efe5697d6f04e5710c3c229afdd10"},
{file = "pre_commit-2.18.1.tar.gz", hash = "sha256:5d445ee1fa8738d506881c5d84f83c62bb5be6b2838e32207433647e8e5ebe10"}, {file = "pre_commit-2.19.0.tar.gz", hash = "sha256:4233a1e38621c87d9dda9808c6606d7e7ba0e087cd56d3fe03202a01d2919615"},
] ]
py = [ py = [
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {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"}, {file = "pymdown_extensions-9.4.tar.gz", hash = "sha256:1baa22a60550f731630474cad28feb0405c8101f1a7ddc3ec0ed86ee510bcc43"},
] ]
pyparsing = [ pyparsing = [
{file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
{file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
] ]
pytest = [ pytest = [
{file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, {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"}, {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"},
] ]
rich = [ rich = [
{file = "rich-12.3.0-py3-none-any.whl", hash = "sha256:0eb63013630c6ee1237e0e395d51cb23513de6b5531235e33889e8842bdf3a6f"}, {file = "rich-12.4.1-py3-none-any.whl", hash = "sha256:d13c6c90c42e24eb7ce660db397e8c398edd58acb7f92a2a88a95572b838aaa4"},
{file = "rich-12.3.0.tar.gz", hash = "sha256:7e8700cda776337036a712ff0495b04052fb5f957c7dfb8df997f88350044b64"}, {file = "rich-12.4.1.tar.gz", hash = "sha256:d239001c0fb7de985e21ec9a4bb542b5150350330bbc1849f835b9cbc8923b91"},
] ]
six = [ six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},

View File

@@ -22,7 +22,7 @@ textual = "textual.cli.cli:run"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.7" python = "^3.7"
rich = "^12.3.0" rich = "^12.4.0"
#rich = {git = "git@github.com:willmcgugan/rich", rev = "link-id"} #rich = {git = "git@github.com:willmcgugan/rich", rev = "link-id"}
click = "8.1.2" click = "8.1.2"

View File

@@ -1,7 +1,7 @@
from textual.app import App from textual.app import App
from textual.widget import Widget 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: def celsius_to_fahrenheit(celsius: float) -> float:
@@ -19,8 +19,10 @@ class InputApp(App[str]):
self.fahrenheit.focus() self.fahrenheit.focus()
text_boxes = Widget(self.fahrenheit, self.celsius) text_boxes = Widget(self.fahrenheit, self.celsius)
self.mount(inputs=text_boxes) 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: try:
value = float(event.value) value = float(event.value)
except ValueError: except ValueError:

View File

@@ -1,11 +1,17 @@
App { App {
layout: dock;
docks: top=top bot=bottom;
background: $secondary; background: $secondary;
} }
#spacer {
height: 1;
background: $primary-darken-2;
dock: top;
}
Screen { Screen {
layout: dock;
docks: top=top bottom=bottom;
background: $secondary; background: $secondary;
} }
@@ -29,3 +35,7 @@ Screen {
background: $secondary; background: $secondary;
height: 20; height: 20;
} }
#text_area {
dock: bottom;
}

View File

@@ -202,7 +202,7 @@ class Keys(str, Enum):
@classmethod @classmethod
def values(cls): def values(cls):
"""Returns a set of all the enum values.""" """Returns a set of all the enum values."""
return set(cls._value2member_map_.keys()) return set(cls._value2member_map_.values())
@dataclass @dataclass

View File

@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
from rich.console import RenderableType from rich.console import RenderableType
from rich.padding import Padding
from rich.text import Text from rich.text import Text
from textual import events from textual import events
from textual._types import MessageTarget from textual._types import MessageTarget
from textual.app import ComposeResult
from textual.geometry import Size from textual.geometry import Size
from textual.keys import Keys from textual.keys import Keys
from textual.message import Message from textual.message import Message
@@ -12,8 +14,8 @@ from textual.reactive import Reactive
from textual.widget import Widget from textual.widget import Widget
class TextInputBase(Widget): class TextWidgetBase(Widget):
ALLOW_PROPAGATE = {} STOP_PROPAGATE = set()
current_text = Reactive("", layout=True) current_text = Reactive("", layout=True)
cursor_index = Reactive(0) cursor_index = Reactive(0)
@@ -24,7 +26,7 @@ class TextInputBase(Widget):
if key == "\x1b": if key == "\x1b":
return return
changed = False repaint = False
if key == "ctrl+h" and self.cursor_index != 0: if key == "ctrl+h" and self.cursor_index != 0:
new_text = ( new_text = (
self.current_text[: self.cursor_index - 1] self.current_text[: self.cursor_index - 1]
@@ -32,14 +34,14 @@ class TextInputBase(Widget):
) )
self.current_text = new_text self.current_text = new_text
self.cursor_index = max(0, self.cursor_index - 1) 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): elif key == "ctrl+d" and self.cursor_index != len(self.current_text):
new_text = ( new_text = (
self.current_text[: self.cursor_index] self.current_text[: self.cursor_index]
+ self.current_text[self.cursor_index + 1 :] + self.current_text[self.cursor_index + 1 :]
) )
self.current_text = new_text self.current_text = new_text
changed = True repaint = True
elif key == "left": elif key == "left":
self.cursor_index = max(0, self.cursor_index - 1) self.cursor_index = max(0, self.cursor_index - 1)
elif key == "right": elif key == "right":
@@ -49,45 +51,60 @@ class TextInputBase(Widget):
elif key == "end": elif key == "end":
self.cursor_index = len(self.current_text) self.cursor_index = len(self.current_text)
elif key not in Keys.values(): elif key not in Keys.values():
new_text = ( self.insert_at_cursor(key)
self.current_text[: self.cursor_index] repaint = True
+ 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
if changed: if repaint:
self.post_message_no_wait(self.Changed(self, value=self.current_text)) 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 = """ CSS = """
TextInput { TextInput {
width: auto; overflow: hidden hidden;
background: $primary; background: $primary-darken-1;
height: 3; scrollbar-color: $primary-darken-2;
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%;
}
""" """
def __init__( def __init__(
@@ -101,8 +118,68 @@ class TextInput(TextInputBase, can_focus=True):
): ):
super().__init__(name=name, id=id, classes=classes) super().__init__(name=name, id=id, classes=classes)
self.placeholder = placeholder self.placeholder = placeholder
self.initial = initial
self.current_text = initial if initial else "" 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: def get_content_width(self, container_size: Size, viewport_size: Size) -> int:
return ( return (
max(len(self.current_text), len(self.placeholder)) 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) display_text = self._apply_cursor_to_text(display_text, 0)
return display_text 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: def on_key(self, event: events.Key) -> None:
key = event.key key = event.key
if key not in self.ALLOW_PROPAGATE: if key in self.STOP_PROPAGATE:
event.stop() event.stop()
if key == "enter" and self.current_text: if key == "enter" and self.current_text:
self.post_message_no_wait(TextInput.Submitted(self, 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: class TextArea(Widget):
sender (MessageTarget): Sender of the message CSS = """
value (str): The value in the TextInput TextArea { overflow: auto auto; height: 5; background: $primary-darken-1; }
""" """
super().__init__(sender)
self.value = value
class Submitted(Message, bubble=True): def compose(self) -> ComposeResult:
def __init__(self, sender: MessageTarget, value: str) -> None: yield TextAreaChild()
"""Message posted when the user presses the 'enter' key while
focused on a TextInput widget.
Args:
sender (MessageTarget): Sender of the message class TextAreaChild(TextWidgetBase, can_focus=True):
value (str): The value in the TextInput # TODO: Not nearly ready for prime-time, but it exists to help
""" # model the superclass.
super().__init__(sender) CSS = "TextAreaChild { height: auto; background: $primary-darken-1; }"
self.value = value 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)