mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
replace TextInput with Input
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
#results-container {
|
||||
background: $background 50%;
|
||||
margin: 1 2;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden auto;
|
||||
border: tall $background;
|
||||
}
|
||||
|
||||
#results-container:focus {
|
||||
border: tall $accent;
|
||||
}
|
||||
|
||||
@@ -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,13 +39,17 @@ 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 = []
|
||||
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("")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,7 +19,6 @@ __all__ = [
|
||||
"Placeholder",
|
||||
"Pretty",
|
||||
"Static",
|
||||
"TextInput",
|
||||
"Input",
|
||||
"TextLog",
|
||||
"TreeControl",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user