Skip to content

Commit 4818130

Browse files
feat: update TextInput to handle text overflow.
- add a _text_offset attribute which records the current left index of the viewable space - refactor key event handling to be more modular - add _text_offset updating logic within the key event handlers - update docstrings in new and existing TextInput methods - add docstrings for attributes to support VSCode on-hover docstrings - update readme with note about support for text overflow Co-authored-by: sirfuzzalot <[email protected]>
1 parent 3531f74 commit 4818130

File tree

2 files changed

+146
-86
lines changed

2 files changed

+146
-86
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ python examples/simple_form.py
5858
### TextInput 🔡
5959

6060
- value - string
61-
- one line of text
61+
- one line of text with overflow support
6262
- placeholder and title support
6363
- password mode to hide input
6464
- syntax mode to highlight code

src/textual_inputs/text_input.py

Lines changed: 145 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@ def syntax_highlight_text(code: str, syntax: str) -> Text:
3737

3838

3939
class TextInput(Widget):
40-
"""
41-
A simple text input widget.
40+
"""A simple text input widget.
4241
4342
Args:
4443
name (Optional[str]): The unique name of the widget. If None, the
@@ -50,21 +49,21 @@ class TextInput(Widget):
5049
of the widget's border.
5150
password (bool, optional): Defaults to False. Hides the text
5251
input, replacing it with bullets.
53-
syntax (Optional[str]): the name of the language for syntax highlighting.
52+
syntax (Optional[str]): The name of the language for syntax highlighting.
5453
5554
Attributes:
56-
value (str): the value of the text field
55+
value (str): The value of the text field
5756
placeholder (str): The placeholder message.
5857
title (str): The displayed title of the widget.
5958
has_password (bool): True if the text field masks the input.
6059
syntax (Optional[str]): the name of the language for syntax highlighting.
6160
has_focus (bool): True if the widget is focused.
6261
cursor (Tuple[str, Style]): The character used for the cursor
6362
and a rich Style object defining its appearance.
64-
on_change_handler_name (str): name of handler function to be
63+
on_change_handler_name (str): The name of handler function to be
6564
called when an on change event occurs. Defaults to
6665
handle_input_on_change.
67-
on_focus_handler_name (name): name of handler function to be
66+
on_focus_handler_name (name): The name of handler function to be
6867
called when an on focus event occurs. Defaults to
6968
handle_input_on_focus.
7069
@@ -95,6 +94,7 @@ class TextInput(Widget):
9594
bold=True,
9695
),
9796
)
97+
"""Character and style of the cursor."""
9898
_cursor_position: Reactive[int] = Reactive(0)
9999
_has_focus: Reactive[bool] = Reactive(False)
100100

@@ -111,13 +111,22 @@ def __init__(
111111
) -> None:
112112
super().__init__(name)
113113
self.value = value
114+
"""The value of the text field"""
114115
self.placeholder = placeholder
116+
"""
117+
Text that appears in the widget when value is "" and the widget
118+
is not focused.
119+
"""
115120
self.title = title
121+
"""The displayed title of the widget."""
116122
self.has_password = password
123+
"""True if the text field masks the input."""
117124
self.syntax = syntax
125+
"""The name of the language for syntax highlighting."""
118126
self._on_change_message_class = InputOnChange
119127
self._on_focus_message_class = InputOnFocus
120128
self._cursor_position = len(self.value)
129+
self._text_offset = 0
121130

122131
def __rich_repr__(self):
123132
yield "name", self.name
@@ -200,119 +209,170 @@ def render(self) -> RenderableType:
200209
)
201210

202211
def _modify_text(self, segment: str) -> Union[str, Text]:
203-
"""
204-
Produces the text with modifications, such as password concealing.
205-
"""
212+
"""Produces the text with modifications, such as password concealing."""
206213
if self.has_password:
207214
return conceal_text(segment)
208215
if self.syntax:
209216
return syntax_highlight_text(segment, self.syntax)
210217
return segment
211218

212-
def _render_text_with_cursor(self) -> List[Union[str, Text, Tuple[str, Style]]]:
219+
@property
220+
def _visible_width(self):
221+
"""Width in characters of the inside of the input"""
222+
# remove 2, 1 for each of the border's edges
223+
# remove 1 more for the cursor
224+
# remove 2 for the padding either side of the input
225+
width, _ = self.size
226+
if self.border:
227+
width -= 2
228+
if self._has_focus:
229+
width -= 1
230+
width -= 2
231+
return width
232+
233+
def _text_offset_window(self):
213234
"""
214-
Produces the renderable Text object combining value and cursor
235+
Produce the start and end indices of the visible portions of the
236+
text value.
215237
"""
238+
return self._text_offset, self._text_offset + self._visible_width
239+
240+
def _render_text_with_cursor(self) -> List[Union[str, Text, Tuple[str, Style]]]:
241+
"""Produces the renderable Text object combining value and cursor"""
216242
text = self._modify_text(self.value)
243+
244+
# trim the string to fit within the widgets dimensions
245+
left, right = self._text_offset_window()
246+
text = text[left:right]
247+
248+
# convert the cursor to be relative to this view
249+
cursor_relative_position = self._cursor_position - self._text_offset
217250
return [
218-
text[: self._cursor_position],
251+
text[:cursor_relative_position],
219252
self.cursor,
220-
text[self._cursor_position :],
253+
text[cursor_relative_position:],
221254
]
222255

223256
async def on_focus(self, event: events.Focus) -> None:
257+
"""Handle Focus events
258+
259+
Args:
260+
event (events.Focus): A Textual Focus event
261+
"""
224262
self._has_focus = True
225263
await self._emit_on_focus()
226264

227265
async def on_blur(self, event: events.Blur) -> None:
266+
"""Handle Blur events
267+
268+
Args:
269+
event (events.Blur): A Textual Blur event
270+
"""
228271
self._has_focus = False
229272

273+
def _update_offset_left(self):
274+
"""
275+
Decrease the text offset if the cursor moves less than 3 characters
276+
from the left edge. This will shift the text to the right and keep
277+
the cursor 3 characters from the left edge. If the text offset is 0
278+
then the cursor may continue to move until it reaches the left edge.
279+
"""
280+
visibility_left = 3
281+
if self._cursor_position < self._text_offset + visibility_left:
282+
self._text_offset = max(0, self._cursor_position - visibility_left)
283+
284+
def _update_offset_right(self):
285+
"""
286+
Increase the text offset if the cursor moves beyond the right
287+
edge of the widget. This will shift the text left and make the
288+
cursor visible at the right edge of the widget.
289+
"""
290+
_, right = self._text_offset_window()
291+
if self._cursor_position > right:
292+
self._text_offset = self._cursor_position - self._visible_width
293+
294+
def _cursor_left(self):
295+
"""Handle key press Left"""
296+
if self._cursor_position > 0:
297+
self._cursor_position -= 1
298+
self._update_offset_left()
299+
300+
def _cursor_right(self):
301+
"""Handle key press Right"""
302+
if self._cursor_position < len(self.value):
303+
self._cursor_position = self._cursor_position + 1
304+
self._update_offset_right()
305+
306+
def _cursor_home(self):
307+
"""Handle key press Home"""
308+
self._cursor_position = 0
309+
self._update_offset_left()
310+
311+
def _cursor_end(self):
312+
"""Handle key press End"""
313+
self._cursor_position = len(self.value)
314+
self._update_offset_right()
315+
316+
def _key_backspace(self):
317+
"""Handle key press Backspace"""
318+
if self._cursor_position > 0:
319+
self.value = (
320+
self.value[: self._cursor_position - 1]
321+
+ self.value[self._cursor_position :]
322+
)
323+
self._cursor_position -= 1
324+
self._update_offset_left()
325+
326+
def _key_delete(self):
327+
"""Handle key press Delete"""
328+
if self._cursor_position < len(self.value):
329+
self.value = (
330+
self.value[: self._cursor_position]
331+
+ self.value[self._cursor_position + 1 :]
332+
)
333+
334+
def _key_printable(self, event: events.Key):
335+
"""Handle all printable keys"""
336+
self.value = (
337+
self.value[: self._cursor_position]
338+
+ event.key
339+
+ self.value[self._cursor_position :]
340+
)
341+
342+
if not self._cursor_position > len(self.value):
343+
self._cursor_position += 1
344+
self._update_offset_right()
345+
230346
async def on_key(self, event: events.Key) -> None:
231-
if event.key == "left":
232-
if self._cursor_position == 0:
233-
self._cursor_position = 0
234-
else:
235-
self._cursor_position -= 1
347+
"""Handle key events
236348
349+
Args:
350+
event (events.Key): A Textual Key event
351+
"""
352+
BACKSPACE = "ctrl+h"
353+
if event.key == "left":
354+
self._cursor_left()
237355
elif event.key == "right":
238-
if self._cursor_position != len(self.value):
239-
self._cursor_position = self._cursor_position + 1
240-
356+
self._cursor_right()
241357
elif event.key == "home":
242-
self._cursor_position = 0
243-
358+
self._cursor_home()
244359
elif event.key == "end":
245-
self._cursor_position = len(self.value)
246-
247-
elif event.key == "ctrl+h": # Backspace
248-
if self._cursor_position == 0:
249-
return
250-
elif len(self.value) == 1:
251-
self.value = ""
252-
self._cursor_position = 0
253-
elif len(self.value) == 2:
254-
if self._cursor_position == 1:
255-
self.value = self.value[1]
256-
self._cursor_position = 0
257-
else:
258-
self.value = self.value[0]
259-
self._cursor_position = 1
260-
else:
261-
if self._cursor_position == 1:
262-
self.value = self.value[1:]
263-
self._cursor_position = 0
264-
elif self._cursor_position == len(self.value):
265-
self.value = self.value[:-1]
266-
self._cursor_position -= 1
267-
else:
268-
self.value = (
269-
self.value[: self._cursor_position - 1]
270-
+ self.value[self._cursor_position :]
271-
)
272-
self._cursor_position -= 1
273-
360+
self._cursor_end()
361+
elif event.key == BACKSPACE:
362+
self._key_backspace()
274363
await self._emit_on_change(event)
275-
276364
elif event.key == "delete":
277-
if self._cursor_position == len(self.value):
278-
return
279-
elif len(self.value) == 1:
280-
self.value = ""
281-
elif len(self.value) == 2:
282-
if self._cursor_position == 1:
283-
self.value = self.value[0]
284-
else:
285-
self.value = self.value[1]
286-
else:
287-
if self._cursor_position == 0:
288-
self.value = self.value[1:]
289-
else:
290-
self.value = (
291-
self.value[: self._cursor_position]
292-
+ self.value[self._cursor_position + 1 :]
293-
)
365+
self._key_delete()
294366
await self._emit_on_change(event)
295-
296367
elif len(event.key) == 1 and event.key.isprintable():
297-
if self._cursor_position == 0:
298-
self.value = event.key + self.value
299-
elif self._cursor_position == len(self.value):
300-
self.value = self.value + event.key
301-
else:
302-
self.value = (
303-
self.value[: self._cursor_position]
304-
+ event.key
305-
+ self.value[self._cursor_position :]
306-
)
307-
308-
if not self._cursor_position > len(self.value):
309-
self._cursor_position += 1
310-
368+
self._key_printable(event)
311369
await self._emit_on_change(event)
312370

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

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

0 commit comments

Comments
 (0)