mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Simple multiline text widget to help model text base class
This commit is contained in:
26
poetry.lock
generated
26
poetry.lock
generated
@@ -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"},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,44 +51,59 @@ class TextInputBase(Widget):
|
||||
elif key == "end":
|
||||
self.cursor_index = len(self.current_text)
|
||||
elif key not in Keys.values():
|
||||
self.insert_at_cursor(key)
|
||||
repaint = True
|
||||
|
||||
if repaint:
|
||||
self.post_message_no_wait(self.Changed(self, value=self.current_text))
|
||||
|
||||
self.refresh(layout=True)
|
||||
|
||||
def insert_at_cursor(self, text: str) -> None:
|
||||
new_text = (
|
||||
self.current_text[: self.cursor_index]
|
||||
+ key
|
||||
+ text
|
||||
+ 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:
|
||||
self.post_message_no_wait(self.Changed(self, value=self.current_text))
|
||||
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(TextInputBase, can_focus=True):
|
||||
|
||||
ALLOW_PROPAGATE = {"tab", "shift+tab"}
|
||||
|
||||
class TextInput(Widget):
|
||||
CSS = """
|
||||
TextInput {
|
||||
width: auto;
|
||||
background: $primary;
|
||||
height: 3;
|
||||
padding: 0 1;
|
||||
content-align: left middle;
|
||||
}
|
||||
|
||||
TextInput:hover {
|
||||
overflow: hidden hidden;
|
||||
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%;
|
||||
scrollbar-color: $primary-darken-2;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
class TextArea(Widget):
|
||||
CSS = """
|
||||
TextArea { overflow: auto auto; height: 5; background: $primary-darken-1; }
|
||||
"""
|
||||
super().__init__(sender)
|
||||
self.value = value
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user