mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
358 lines
12 KiB
Python
358 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
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 ..message import Message
|
|
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
|
|
|
|
def __rich_console__(
|
|
self, console: "Console", options: "ConsoleOptions"
|
|
) -> "RenderResult":
|
|
|
|
input = self.input
|
|
result = input._value
|
|
if input._cursor_at_end:
|
|
result.pad_right(1)
|
|
cursor_style = input.get_component_rich_style("input--cursor")
|
|
if self.cursor_visible and input.has_focus:
|
|
cursor = input.cursor_position
|
|
result.stylize(cursor_style, cursor, cursor + 1)
|
|
width = input.content_size.width
|
|
segments = list(result.render(console))
|
|
line_length = Segment.get_line_length(segments)
|
|
if line_length < width:
|
|
segments = Segment.adjust_line_length(segments, width)
|
|
line_length = width
|
|
|
|
line = line_crop(
|
|
list(segments),
|
|
input.view_position,
|
|
input.view_position + width,
|
|
line_length,
|
|
)
|
|
yield from line
|
|
|
|
|
|
class Input(Widget, can_focus=True):
|
|
"""A text input widget."""
|
|
|
|
DEFAULT_CSS = """
|
|
Input {
|
|
background: $boost;
|
|
color: $text;
|
|
padding: 0 2;
|
|
border: tall $background;
|
|
width: 100%;
|
|
height: 1;
|
|
min-height: 1;
|
|
}
|
|
Input.-disabled {
|
|
opacity: 0.6;
|
|
}
|
|
Input:focus {
|
|
border: tall $accent;
|
|
}
|
|
Input>.input--cursor {
|
|
background: $surface;
|
|
color: $text;
|
|
text-style: reverse;
|
|
}
|
|
Input>.input--placeholder {
|
|
color: $text-disabled;
|
|
}
|
|
"""
|
|
|
|
BINDINGS = [
|
|
Binding("left", "cursor_left", "cursor left", show=False),
|
|
Binding("right", "cursor_right", "cursor right", show=False),
|
|
Binding("backspace", "delete_left", "delete left", show=False),
|
|
Binding("home", "home", "home", show=False),
|
|
Binding("end", "end", "end", show=False),
|
|
Binding("ctrl+d", "delete_right", "delete right", show=False),
|
|
Binding("enter", "submit", "submit", show=False),
|
|
]
|
|
|
|
COMPONENT_CLASSES = {"input--cursor", "input--placeholder"}
|
|
|
|
cursor_blink = reactive(True)
|
|
value = reactive("", layout=True, init=False)
|
|
input_scroll_offset = reactive(0)
|
|
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)
|
|
|
|
def __init__(
|
|
self,
|
|
value: str | None = None,
|
|
placeholder: str = "",
|
|
highlighter: Highlighter | None = None,
|
|
password: bool = False,
|
|
name: str | None = None,
|
|
id: str | None = None,
|
|
classes: str | None = None,
|
|
) -> None:
|
|
"""Initialise the `Input` widget.
|
|
|
|
Args:
|
|
value: An optional default value for the input.
|
|
placeholder: Optional placeholder text for the input.
|
|
highlighter: An optional highlighter for the input.
|
|
password: Flag to say if the field should obfuscate its content. Default is `False`.
|
|
name: Optional name for the input widget.
|
|
id: Optional ID for the widget.
|
|
classes: Optional initial classes for the widget.
|
|
"""
|
|
super().__init__(name=name, id=id, classes=classes)
|
|
if value is not None:
|
|
self.value = value
|
|
self.placeholder = placeholder
|
|
self.highlighter = highlighter
|
|
self.password = password
|
|
|
|
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:
|
|
"""The cell offset of the cursor."""
|
|
offset = self._position_to_cell(self.cursor_position)
|
|
if self._cursor_at_end:
|
|
offset += 1
|
|
return offset
|
|
|
|
@property
|
|
def _cursor_at_end(self) -> bool:
|
|
"""Flag to indicate if the cursor is at the end"""
|
|
return self.cursor_position >= len(self.value)
|
|
|
|
def validate_cursor_position(self, cursor_position: int) -> int:
|
|
return min(max(0, cursor_position), len(self.value))
|
|
|
|
def validate_view_position(self, view_position: int) -> int:
|
|
width = self.content_size.width
|
|
new_view_position = max(0, min(view_position, self.cursor_width - width))
|
|
return new_view_position
|
|
|
|
def watch_cursor_position(self, cursor_position: int) -> None:
|
|
width = self.content_size.width
|
|
if width == 0:
|
|
# If the input has no width the view position can't be elsewhere.
|
|
self.view_position = 0
|
|
return
|
|
|
|
view_start = self.view_position
|
|
view_end = view_start + width
|
|
cursor_offset = self._cursor_offset
|
|
|
|
if cursor_offset >= view_end or cursor_offset < view_start:
|
|
view_position = cursor_offset - width // 2
|
|
self.view_position = view_position
|
|
else:
|
|
self.view_position = self.view_position
|
|
|
|
async def watch_value(self, value: str) -> None:
|
|
if self.styles.auto_dimensions:
|
|
self.refresh(layout=True)
|
|
await self.emit(self.Changed(self, value))
|
|
|
|
@property
|
|
def cursor_width(self) -> int:
|
|
"""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
|
|
|
|
def render(self) -> RenderableType:
|
|
if not self.value:
|
|
placeholder = Text(self.placeholder, justify="left")
|
|
placeholder.stylize(self.get_component_rich_style("input--placeholder"))
|
|
if self.has_focus:
|
|
cursor_style = self.get_component_rich_style("input--cursor")
|
|
if self._cursor_visible:
|
|
# If the placeholder is empty, there's no characters to stylise
|
|
# to make the cursor flash, so use a single space character
|
|
if len(placeholder) == 0:
|
|
placeholder = Text(" ")
|
|
placeholder.stylize(cursor_style, 0, 1)
|
|
return placeholder
|
|
return _InputRenderable(self, self._cursor_visible)
|
|
|
|
@property
|
|
def _value(self) -> Text:
|
|
"""Value rendered as text."""
|
|
if self.password:
|
|
return Text("•" * len(self.value), no_wrap=True, overflow="ignore")
|
|
else:
|
|
text = Text(self.value, no_wrap=True, overflow="ignore")
|
|
if self.highlighter is not None:
|
|
text = self.highlighter(text)
|
|
return text
|
|
|
|
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:
|
|
"""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,
|
|
pause=not (self.cursor_blink and self.has_focus),
|
|
)
|
|
|
|
def on_blur(self) -> None:
|
|
self.blink_timer.pause()
|
|
|
|
def on_focus(self) -> None:
|
|
self.cursor_position = len(self.value)
|
|
if self.cursor_blink:
|
|
self.blink_timer.resume()
|
|
|
|
async def on_key(self, event: events.Key) -> None:
|
|
self._cursor_visible = True
|
|
if self.cursor_blink:
|
|
self.blink_timer.reset()
|
|
|
|
# Do key bindings first
|
|
if await self.handle_key(event):
|
|
event.prevent_default()
|
|
event.stop()
|
|
return
|
|
elif event.is_printable:
|
|
event.stop()
|
|
assert event.character is not None
|
|
self.insert_text_at_cursor(event.character)
|
|
event.prevent_default()
|
|
|
|
def on_paste(self, event: events.Paste) -> None:
|
|
line = event.text.splitlines()[0]
|
|
self.insert_text_at_cursor(line)
|
|
event.stop()
|
|
|
|
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:
|
|
"""Insert new text at the cursor, move the cursor to the end of the new text.
|
|
|
|
Args:
|
|
text: New text to insert.
|
|
"""
|
|
if self.cursor_position > len(self.value):
|
|
self.value += text
|
|
self.cursor_position = len(self.value)
|
|
else:
|
|
value = self.value
|
|
before = value[: self.cursor_position]
|
|
after = value[self.cursor_position :]
|
|
self.value = f"{before}{text}{after}"
|
|
self.cursor_position += len(text)
|
|
|
|
def action_cursor_left(self) -> None:
|
|
self.cursor_position -= 1
|
|
|
|
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 <= 0:
|
|
# Cursor at the start, so nothing to delete
|
|
return
|
|
if self.cursor_position == len(self.value):
|
|
# Delete from end
|
|
self.value = self.value[:-1]
|
|
self.cursor_position = len(self.value)
|
|
else:
|
|
# Cursor in the middle
|
|
value = self.value
|
|
delete_position = self.cursor_position - 1
|
|
before = value[:delete_position]
|
|
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))
|
|
|
|
class Changed(Message, bubble=True):
|
|
"""Value was changed.
|
|
|
|
Attributes:
|
|
value: The value that the input was changed to.
|
|
input: The `Input` widget that was changed.
|
|
"""
|
|
|
|
def __init__(self, sender: Input, value: str) -> None:
|
|
super().__init__(sender)
|
|
self.value: str = value
|
|
self.input: Input = sender
|
|
|
|
class Submitted(Message, bubble=True):
|
|
"""Sent when the enter key is pressed within an `Input`.
|
|
|
|
Attributes:
|
|
value: The value of the `Input` being submitted..
|
|
input: The `Input` widget that is being submitted.
|
|
"""
|
|
|
|
def __init__(self, sender: Input, value: str) -> None:
|
|
super().__init__(sender)
|
|
self.value: str = value
|
|
self.input: Input = sender
|