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]]
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"},

View File

@@ -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"

View File

@@ -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:

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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)