Skip to content

Commit

Permalink
Merge pull request #528 from Textualize/text-input-cursor-to-click
Browse files Browse the repository at this point in the history
Text input improvements
willmcgugan authored May 25, 2022

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
2 parents 2b485cd + 2d7f0b1 commit 3e4721e
Showing 6 changed files with 344 additions and 55 deletions.
71 changes: 71 additions & 0 deletions sandbox/file_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from __future__ import annotations

from pathlib import Path
from typing import Iterable

from rich.console import RenderableType
from rich.style import Style
from rich.table import Table
from rich.text import Text

from textual.app import App
from textual.geometry import Size
from textual.reactive import Reactive
from textual.widget import Widget
from textual.widgets.text_input import TextInput, TextWidgetBase


def get_files() -> list[Path]:
files = list(Path.cwd().iterdir())
return files


class FileTable(Widget):
filter = Reactive("", layout=True)

def __init__(self, *args, files: Iterable[Path] | None = None, **kwargs):
super().__init__(*args, **kwargs)
self.files = files if files is not None else []

@property
def filtered_files(self) -> list[Path]:
return [
file
for file in self.files
if self.filter == "" or (self.filter and self.filter in file.name)
]

def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
return len(self.filtered_files)

def render(self, style: Style) -> RenderableType:
grid = Table.grid()
grid.add_column()
for file in self.filtered_files:
file_text = Text(f" {file.name}")
if self.filter:
file_text.highlight_regex(self.filter, "black on yellow")
grid.add_row(file_text)
return grid


class FileSearchApp(App):
dark = True

def on_mount(self) -> None:
self.file_table = FileTable(id="file_table", files=list(Path.cwd().iterdir()))
self.search_bar = TextInput(placeholder="Search for files...")
self.search_bar.focus()
self.mount(file_table_wrapper=Widget(self.file_table))
self.mount(search_bar=self.search_bar)

def handle_changed(self, event: TextWidgetBase.Changed) -> None:
self.file_table.filter = event.value


app = FileSearchApp(
log_path="textual.log", css_path="file_search.scss", watch_css=True, log_verbosity=2
)

if __name__ == "__main__":
result = app.run()
21 changes: 21 additions & 0 deletions sandbox/file_search.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Screen {
layout: dock;
docks: top=top bottom=bottom;
}

#file_table_wrapper {
dock: bottom;
height: auto;
overflow: auto auto;
scrollbar-color: $accent-darken-1;
}

#file_table {
height: auto;
}

#search_bar {
dock: bottom;
background: $accent;
height: 1;
}
28 changes: 26 additions & 2 deletions sandbox/input.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
from __future__ import annotations

from pathlib import Path

from textual.app import App
from textual.widget import Widget

@@ -12,6 +16,18 @@ def fahrenheit_to_celsius(fahrenheit: float) -> float:
return (fahrenheit - 32) / 1.8


words = set(Path("/usr/share/dict/words").read_text().splitlines())


def word_autocompleter(value: str) -> str | None:
# An example autocompleter that uses the Unix dictionary to suggest
# word completions
for word in words:
if word.startswith(value):
return word
return None


class InputApp(App[str]):
def on_mount(self) -> None:
self.fahrenheit = TextInput(placeholder="Fahrenheit", id="fahrenheit")
@@ -20,7 +36,16 @@ def on_mount(self) -> None:
text_boxes = Widget(self.fahrenheit, self.celsius)
self.mount(inputs=text_boxes)
self.mount(spacer=Widget())
self.mount(footer=TextInput(placeholder="Footer Search Bar"))
self.mount(
top_search=Widget(
TextInput(autocompleter=word_autocompleter, id="topsearchbox")
)
)
self.mount(
footer=TextInput(
placeholder="Footer Search Bar", autocompleter=word_autocompleter
)
)
self.mount(text_area=TextArea())

def handle_changed(self, event: TextWidgetBase.Changed) -> None:
@@ -42,4 +67,3 @@ def handle_changed(self, event: TextWidgetBase.Changed) -> None:

if __name__ == "__main__":
result = app.run()
print(repr(result))
15 changes: 10 additions & 5 deletions sandbox/input.scss
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@ App {
Screen {
layout: dock;
docks: top=top bottom=bottom;
background: $secondary;
background: $background;
}

#fahrenheit {
@@ -37,12 +37,17 @@ Screen {
dock: bottom;
}

#top_search {
dock: top;
}

#topsearchbox {
width: 10%;
}

#footer {
background: $primary-darken-2;
dock: bottom;
height: 1;
border: ;
}

#footer :focus {
border: heavy $secondary;
}
18 changes: 14 additions & 4 deletions src/textual/_text_backend.py
Original file line number Diff line number Diff line change
@@ -74,7 +74,7 @@ def cursor_right(self) -> bool:
return previous_index != new_index

def query_cursor_left(self) -> bool:
"""Check if the cursor can move 1 character left in the text.
"""Check if the cursor can move 1 codepoint left in the text.
Returns:
bool: True if the cursor can move left. False otherwise.
@@ -83,15 +83,21 @@ def query_cursor_left(self) -> bool:
new_index = max(0, previous_index - 1)
return previous_index != new_index

def query_cursor_right(self) -> bool:
def query_cursor_right(self) -> str | None:
"""Check if the cursor can move right (we can't move right if we're at the end)
and return the codepoint to the right of the cursor if it exists. If it doesn't
exist (e.g. we're at the end), then return None
Returns:
bool: True if the cursor can move right. False otherwise.
str: The codepoint to the right of the cursor if it exists, otherwise None.
"""
previous_index = self.cursor_index
new_index = min(len(self.content), previous_index + 1)
return previous_index != new_index
if new_index == len(self.content):
return None
elif previous_index != new_index:
return self.content[new_index]
return None

def cursor_text_start(self) -> bool:
"""Move the cursor to the start of the text
@@ -147,3 +153,7 @@ def get_range(self, start: int, end: int) -> str:
str: The sliced string between start and end.
"""
return self.content[start:end]

@property
def cursor_at_end(self):
return self.cursor_index == len(self.content)
246 changes: 202 additions & 44 deletions src/textual/widgets/text_input.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
from __future__ import annotations

import time
from typing import Callable

from rich.cells import cell_len
from rich.console import RenderableType
from rich.padding import Padding
from rich.style import Style
from rich.text import Text

from textual import events
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
from textual.geometry import Size, clamp
from textual.message import Message
from textual.reactive import Reactive
from textual.widget import Widget


@@ -20,6 +26,9 @@ class TextWidgetBase(Widget):
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,
@@ -31,31 +40,34 @@ def __init__(

def on_key(self, event: events.Key) -> None:
key = event.key
if key == "\x1b":
if key == "escape":
return

changed = False
if key == "ctrl+h":
if event.is_printable:
changed = self._editor.insert_at_cursor(key)
elif key == "ctrl+h":
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":
elif key == "home" or key == "ctrl+a":
self._editor.cursor_text_start()
elif key == "end":
elif key == "end" or key == "ctrl+e":
self._editor.cursor_text_end()
elif event.is_printable:
changed = self._editor.insert_at_cursor(key)

if changed:
self.post_message_no_wait(self.Changed(self, value=self._editor.content))

self.refresh(layout=True)

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"
@@ -89,24 +101,24 @@ def __init__(self, sender: MessageTarget, value: str) -> None:


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.
"""

CSS = """
TextInput {
width: auto;
background: $primary;
background: $surface;
height: 3;
padding: 0 1;
content-align: left middle;
background: $primary-darken-1;
}
TextInput:hover {
background: $primary-darken-2;
}
TextInput:focus {
background: $primary-darken-2;
border: heavy $primary-lighten-1;
padding: 0;
}
"""

@@ -115,6 +127,7 @@ def __init__(
*,
placeholder: str = "",
initial: str = "",
autocompleter: Callable[[str], str | None] | None = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
@@ -123,6 +136,17 @@ def __init__(
self.placeholder = placeholder
self._editor = TextEditorBackend(initial, 0)
self.visible_range: tuple[int, int] | None = None
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:
@@ -143,11 +167,58 @@ def value(self, value: str) -> None:

def on_resize(self, event: events.Resize) -> None:
# Ensure the cursor remains visible when the widget is resized
new_visible_range_end = max(
self._editor.cursor_index + 1, self.content_region.width
self._reset_visible_range()

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 _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,
)
new_visible_range_start = new_visible_range_end - self.content_region.width
self.visible_range = (new_visible_range_start, new_visible_range_end)

self.refresh()

def render(self, style: Style) -> RenderableType:
@@ -156,11 +227,15 @@ def render(self, style: Style) -> RenderableType:
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
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
@@ -180,18 +255,68 @@ def on_key(self, event: events.Key) -> None:
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 = len(self._editor.content) >= available_width
if key == "enter" and self._editor.content:
scrollable = cell_len(self._editor.content) >= available_width

# Check what content is visible from the editor, and how wide that content is
visible_content = self._editor.get_range(start, end)
visible_content_cell_len = cell_len(visible_content)
visible_content_to_cursor = self._editor.get_range(
start, self._editor.cursor_index + 1
)
visible_content_to_cursor_cell_len = cell_len(visible_content_to_cursor)

cursor_at_end = visible_content_to_cursor_cell_len == available_width
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.visible_range = start + key_cell_len, end + key_cell_len
self._update_suggestion(event)
elif (
key == "ctrl+x"
): # TODO: This allows us to query and print the text input state
self.log(start=start)
self.log(end=end)
self.log(visible_content=visible_content)
self.log(visible_content_cell_len=visible_content_cell_len)
self.log(
visible_content_to_cursor_cell_len=visible_content_to_cursor_cell_len
)
self.log(available_width=available_width)
self.log(cursor_index=self._editor.cursor_index)
elif key == "enter" and self._editor.content:
self.post_message_no_wait(TextInput.Submitted(self, self._editor.content))
elif key == "right":
if cursor_index == end - 1:
if scrollable and self._editor.query_cursor_right():
self.visible_range = (start + 1, end + 1)
else:
self.app.bell()
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.visible_range = (
start + window_shift_amount,
end + window_shift_amount,
)
if self._suggestion_suffix and self._editor.cursor_at_end:
self._editor.insert_at_cursor(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():
@@ -200,33 +325,66 @@ def on_key(self, event: events.Key) -> None:
cursor_index + available_width - 1,
)
else:
# If the user has hit the scroll limit
self.app.bell()
elif key == "ctrl+h":
if cursor_index == start and self._editor.query_cursor_left():
self.visible_range = start - 1, end - 1
elif key == "home":
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":
value_length = len(self.value)
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 = (
value_length - available_width + 1,
max(available_width, value_length) + 1,
num_codepoints - available_width + additional_shift_required,
max(available_width, num_codepoints) + additional_shift_required,
)
else:
self.visible_range = (0, available_width)
elif event.is_printable:
# If we're at the end of the visible range, and the editor backend
# will permit us to move the cursor right, then shift the visible
# window/range along to the right.
if cursor_index == end - 1:
self.visible_range = start + 1, end + 1

# 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

0 comments on commit 3e4721e

Please sign in to comment.