Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ python examples/simple_form.py
### TextInput 🔡

- value - string
- one line of text
- one line of text with overflow support
- placeholder and title support
- password mode to hide input
- syntax mode to highlight code
Expand Down
230 changes: 145 additions & 85 deletions src/textual_inputs/text_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ def syntax_highlight_text(code: str, syntax: str) -> Text:


class TextInput(Widget):
"""
A simple text input widget.
"""A simple text input widget.

Args:
name (Optional[str]): The unique name of the widget. If None, the
Expand All @@ -50,21 +49,21 @@ class TextInput(Widget):
of the widget's border.
password (bool, optional): Defaults to False. Hides the text
input, replacing it with bullets.
syntax (Optional[str]): the name of the language for syntax highlighting.
syntax (Optional[str]): The name of the language for syntax highlighting.

Attributes:
value (str): the value of the text field
value (str): The value of the text field
placeholder (str): The placeholder message.
title (str): The displayed title of the widget.
has_password (bool): True if the text field masks the input.
syntax (Optional[str]): the name of the language for syntax highlighting.
has_focus (bool): True if the widget is focused.
cursor (Tuple[str, Style]): The character used for the cursor
and a rich Style object defining its appearance.
on_change_handler_name (str): name of handler function to be
on_change_handler_name (str): The name of handler function to be
called when an on change event occurs. Defaults to
handle_input_on_change.
on_focus_handler_name (name): name of handler function to be
on_focus_handler_name (name): The name of handler function to be
called when an on focus event occurs. Defaults to
handle_input_on_focus.

Expand Down Expand Up @@ -95,6 +94,7 @@ class TextInput(Widget):
bold=True,
),
)
"""Character and style of the cursor."""
_cursor_position: Reactive[int] = Reactive(0)
_has_focus: Reactive[bool] = Reactive(False)

Expand All @@ -111,13 +111,22 @@ def __init__(
) -> None:
super().__init__(name)
self.value = value
"""The value of the text field"""
self.placeholder = placeholder
"""
Text that appears in the widget when value is "" and the widget
is not focused.
"""
self.title = title
"""The displayed title of the widget."""
self.has_password = password
"""True if the text field masks the input."""
self.syntax = syntax
"""The name of the language for syntax highlighting."""
self._on_change_message_class = InputOnChange
self._on_focus_message_class = InputOnFocus
self._cursor_position = len(self.value)
self._text_offset = 0

def __rich_repr__(self):
yield "name", self.name
Expand Down Expand Up @@ -200,119 +209,170 @@ def render(self) -> RenderableType:
)

def _modify_text(self, segment: str) -> Union[str, Text]:
"""
Produces the text with modifications, such as password concealing.
"""
"""Produces the text with modifications, such as password concealing."""
if self.has_password:
return conceal_text(segment)
if self.syntax:
return syntax_highlight_text(segment, self.syntax)
return segment

def _render_text_with_cursor(self) -> List[Union[str, Text, Tuple[str, Style]]]:
@property
def _visible_width(self):
"""Width in characters of the inside of the input"""
# remove 2, 1 for each of the border's edges
# remove 1 more for the cursor
# remove 2 for the padding either side of the input
width, _ = self.size
if self.border:
width -= 2
if self._has_focus:
width -= 1
width -= 2
return width

def _text_offset_window(self):
"""
Produces the renderable Text object combining value and cursor
Produce the start and end indices of the visible portions of the
text value.
"""
return self._text_offset, self._text_offset + self._visible_width

def _render_text_with_cursor(self) -> List[Union[str, Text, Tuple[str, Style]]]:
"""Produces the renderable Text object combining value and cursor"""
text = self._modify_text(self.value)

# trim the string to fit within the widgets dimensions
left, right = self._text_offset_window()
text = text[left:right]

# convert the cursor to be relative to this view
cursor_relative_position = self._cursor_position - self._text_offset
return [
text[: self._cursor_position],
text[:cursor_relative_position],
self.cursor,
text[self._cursor_position :],
text[cursor_relative_position:],
]

async def on_focus(self, event: events.Focus) -> None:
"""Handle Focus events

Args:
event (events.Focus): A Textual Focus event
"""
self._has_focus = True
await self._emit_on_focus()

async def on_blur(self, event: events.Blur) -> None:
"""Handle Blur events

Args:
event (events.Blur): A Textual Blur event
"""
self._has_focus = False

def _update_offset_left(self):
"""
Decrease the text offset if the cursor moves less than 3 characters
from the left edge. This will shift the text to the right and keep
the cursor 3 characters from the left edge. If the text offset is 0
then the cursor may continue to move until it reaches the left edge.
"""
visibility_left = 3
if self._cursor_position < self._text_offset + visibility_left:
self._text_offset = max(0, self._cursor_position - visibility_left)

def _update_offset_right(self):
"""
Increase the text offset if the cursor moves beyond the right
edge of the widget. This will shift the text left and make the
cursor visible at the right edge of the widget.
"""
_, right = self._text_offset_window()
if self._cursor_position > right:
self._text_offset = self._cursor_position - self._visible_width

def _cursor_left(self):
"""Handle key press Left"""
if self._cursor_position > 0:
self._cursor_position -= 1
self._update_offset_left()

def _cursor_right(self):
"""Handle key press Right"""
if self._cursor_position < len(self.value):
self._cursor_position = self._cursor_position + 1
self._update_offset_right()

def _cursor_home(self):
"""Handle key press Home"""
self._cursor_position = 0
self._update_offset_left()

def _cursor_end(self):
"""Handle key press End"""
self._cursor_position = len(self.value)
self._update_offset_right()

def _key_backspace(self):
"""Handle key press Backspace"""
if self._cursor_position > 0:
self.value = (
self.value[: self._cursor_position - 1]
+ self.value[self._cursor_position :]
)
self._cursor_position -= 1
self._update_offset_left()

def _key_delete(self):
"""Handle key press Delete"""
if self._cursor_position < len(self.value):
self.value = (
self.value[: self._cursor_position]
+ self.value[self._cursor_position + 1 :]
)

def _key_printable(self, event: events.Key):
"""Handle all printable keys"""
self.value = (
self.value[: self._cursor_position]
+ event.key
+ self.value[self._cursor_position :]
)

if not self._cursor_position > len(self.value):
self._cursor_position += 1
self._update_offset_right()

async def on_key(self, event: events.Key) -> None:
if event.key == "left":
if self._cursor_position == 0:
self._cursor_position = 0
else:
self._cursor_position -= 1
"""Handle key events

Args:
event (events.Key): A Textual Key event
"""
BACKSPACE = "ctrl+h"
if event.key == "left":
self._cursor_left()
elif event.key == "right":
if self._cursor_position != len(self.value):
self._cursor_position = self._cursor_position + 1

self._cursor_right()
elif event.key == "home":
self._cursor_position = 0

self._cursor_home()
elif event.key == "end":
self._cursor_position = len(self.value)

elif event.key == "ctrl+h": # Backspace
if self._cursor_position == 0:
return
elif len(self.value) == 1:
self.value = ""
self._cursor_position = 0
elif len(self.value) == 2:
if self._cursor_position == 1:
self.value = self.value[1]
self._cursor_position = 0
else:
self.value = self.value[0]
self._cursor_position = 1
else:
if self._cursor_position == 1:
self.value = self.value[1:]
self._cursor_position = 0
elif self._cursor_position == len(self.value):
self.value = self.value[:-1]
self._cursor_position -= 1
else:
self.value = (
self.value[: self._cursor_position - 1]
+ self.value[self._cursor_position :]
)
self._cursor_position -= 1

self._cursor_end()
elif event.key == BACKSPACE:
self._key_backspace()
await self._emit_on_change(event)

elif event.key == "delete":
if self._cursor_position == len(self.value):
return
elif len(self.value) == 1:
self.value = ""
elif len(self.value) == 2:
if self._cursor_position == 1:
self.value = self.value[0]
else:
self.value = self.value[1]
else:
if self._cursor_position == 0:
self.value = self.value[1:]
else:
self.value = (
self.value[: self._cursor_position]
+ self.value[self._cursor_position + 1 :]
)
self._key_delete()
await self._emit_on_change(event)

elif len(event.key) == 1 and event.key.isprintable():
if self._cursor_position == 0:
self.value = event.key + self.value
elif self._cursor_position == len(self.value):
self.value = self.value + event.key
else:
self.value = (
self.value[: self._cursor_position]
+ event.key
+ self.value[self._cursor_position :]
)

if not self._cursor_position > len(self.value):
self._cursor_position += 1

self._key_printable(event)
await self._emit_on_change(event)

async def _emit_on_change(self, event: events.Key) -> None:
"""Emit custom message class on Change events"""
event.stop()
await self.emit(self._on_change_message_class(self))

async def _emit_on_focus(self) -> None:
"""Emit custom message class on Focus events"""
await self.emit(self._on_focus_message_class(self))