Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes for Input widget cursor visual glitches #4773

Merged
merged 8 commits into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from 7 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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Changed

- Input cursor will no longer jump to the end on focus https://github.com/Textualize/textual/pull/4773

### Fixed

- Input cursor blink effect will now restart correctly when any action is performed on the input https://github.com/Textualize/textual/pull/4773

## [0.75.1] - 2024-08-02

Expand Down
56 changes: 39 additions & 17 deletions src/textual/widgets/_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from ..events import Blur, Focus, Mount
from ..geometry import Offset, Size
from ..message import Message
from ..reactive import reactive, var
from ..reactive import Reactive, reactive, var
from ..suggester import Suggester, SuggestionReady
from ..timer import Timer
from ..validation import ValidationResult, Validator
Expand Down Expand Up @@ -174,7 +174,7 @@ class Input(Widget, can_focus=True):
cursor_blink = reactive(True, init=False)
value = reactive("", layout=True, init=False)
input_scroll_offset = reactive(0)
cursor_position = reactive(0)
cursor_position: Reactive[int] = reactive(0)
view_position = reactive(0)
placeholder = reactive("")
complete = reactive("")
Expand Down Expand Up @@ -335,8 +335,11 @@ def __init__(
elif self.type == "number":
self.validators.append(Number())

self._initial_value = True
"""Indicates if the value has been set for the first time yet."""
if value is not None:
self.value = value

if tooltip is not None:
self.tooltip = tooltip

Expand Down Expand Up @@ -391,8 +394,8 @@ def _watch_cursor_blink(self, blink: bool) -> None:
if blink:
self._blink_timer.resume()
else:
self._pause_blink_cycle()
self._cursor_visible = True
self._blink_timer.pause()

@property
def cursor_screen_offset(self) -> Offset:
Expand All @@ -412,6 +415,11 @@ def _watch_value(self, value: str) -> None:
)
self.post_message(self.Changed(self, value, validation_result))

# If this is the first time the value has been updated, set the cursor position to the end
if self._initial_value:
self.cursor_position = len(self.value)
self._initial_value = False

def _watch_valid_empty(self) -> None:
"""Repeat validation when valid_empty changes."""
self._watch_value(self.value)
Expand Down Expand Up @@ -506,32 +514,25 @@ def _toggle_cursor(self) -> None:
"""Toggle visibility of cursor."""
self._cursor_visible = not self._cursor_visible

def _on_mount(self, _: Mount) -> None:
def _on_mount(self, event: Mount) -> 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, _: Blur) -> None:
assert self._blink_timer is not None
self._blink_timer.pause()
def _on_blur(self, event: Blur) -> None:
self._pause_blink_cycle()
if "blur" in self.validate_on:
self.validate(self.value)

def _on_focus(self, _: Focus) -> None:
assert self._blink_timer is not None
self.cursor_position = len(self.value)
if self.cursor_blink:
self._blink_timer.resume()
def _on_focus(self, event: Focus) -> None:
self._restart_blink_cycle()
self.app.cursor_position = self.cursor_screen_offset
self._suggestion = ""

async def _on_key(self, event: events.Key) -> None:
assert self._blink_timer is not None
self._cursor_visible = True
if self.cursor_blink:
self._blink_timer.reset()
self._restart_blink_cycle()

if event.is_printable:
event.stop()
Expand Down Expand Up @@ -562,11 +563,29 @@ async def _on_click(self, event: events.Click) -> None:
else:
self.cursor_position = len(self.value)

async def _on_mouse_down(self, event: events.MouseDown) -> None:
self._pause_blink_cycle()

async def _on_mouse_up(self, event: events.MouseUp) -> None:
self._restart_blink_cycle()

async def _on_suggestion_ready(self, event: SuggestionReady) -> None:
"""Handle suggestion messages and set the suggestion when relevant."""
if event.value == self.value:
self._suggestion = event.suggestion

def _restart_blink_cycle(self) -> None:
"""Restart the cursor blink cycle."""
self._cursor_visible = True
if self.cursor_blink and self._blink_timer:
self._blink_timer.reset()

def _pause_blink_cycle(self) -> None:
"""Hide the blinking cursor and pause the blink cycle."""
self._cursor_visible = False
if self.cursor_blink and self._blink_timer:
self._blink_timer.pause()

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.

Expand Down Expand Up @@ -723,6 +742,7 @@ def action_delete_left(self) -> None:

def action_delete_left_word(self) -> None:
"""Delete leftward of the cursor position to the start of a word."""
print(self.cursor_position)
if self.cursor_position <= 0:
return
if self.password:
Expand All @@ -739,7 +759,9 @@ def action_delete_left_word(self) -> None:
self.cursor_position = 0
else:
self.cursor_position = hit.start()
self.value = f"{self.value[: self.cursor_position]}{after}"
new_value = f"{self.value[: self.cursor_position]}{after}"
print(new_value)
self.value = new_value

def action_delete_left_all(self) -> None:
"""Delete all characters to the left of the cursor position."""
Expand Down
10 changes: 7 additions & 3 deletions tests/input/test_input_key_modification_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ async def test_delete_left_from_home() -> None:
"""Deleting left from home should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.cursor_position = 0
input.action_delete_left()
assert input.cursor_position == 0
assert input.value == TEST_INPUTS[input.id]
Expand All @@ -44,6 +45,7 @@ async def test_delete_left_word_from_home() -> None:
"""Deleting word left from home should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.cursor_position = 0
input.action_delete_left_word()
assert input.cursor_position == 0
assert input.value == TEST_INPUTS[input.id]
Expand All @@ -68,7 +70,6 @@ async def test_delete_left_word_from_end() -> None:
"multi-and-hyphenated": "Long as she does it quiet-",
}
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_left_word()
assert input.cursor_position == len(input.value)
assert input.value == expected[input.id]
Expand All @@ -78,7 +79,6 @@ async def test_password_delete_left_word_from_end() -> None:
"""Deleting word left from end of a password input should delete everything."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.password = True
input.action_delete_left_word()
assert input.cursor_position == 0
Expand All @@ -89,6 +89,7 @@ async def test_delete_left_all_from_home() -> None:
"""Deleting all left from home should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.cursor_position = 0
input.action_delete_left_all()
assert input.cursor_position == 0
assert input.value == TEST_INPUTS[input.id]
Expand All @@ -108,6 +109,7 @@ async def test_delete_right_from_home() -> None:
"""Deleting right from home should delete one character (if there is any to delete)."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.cursor_position = 0
input.action_delete_right()
assert input.cursor_position == 0
assert input.value == TEST_INPUTS[input.id][1:]
Expand All @@ -117,7 +119,6 @@ async def test_delete_right_from_end() -> None:
"""Deleting right from end should not change the input's value."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_right()
assert input.cursor_position == len(input.value)
assert input.value == TEST_INPUTS[input.id]
Expand All @@ -133,6 +134,7 @@ async def test_delete_right_word_from_home() -> None:
"multi-and-hyphenated": "as she does it quiet-like",
}
for input in pilot.app.query(Input):
input.cursor_position = 0
input.action_delete_right_word()
assert input.cursor_position == 0
assert input.value == expected[input.id]
Expand All @@ -142,6 +144,7 @@ async def test_password_delete_right_word_from_home() -> None:
"""Deleting word right from home of a password input should delete everything."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.cursor_position = 0
input.password = True
input.action_delete_right_word()
assert input.cursor_position == 0
Expand All @@ -162,6 +165,7 @@ async def test_delete_right_all_from_home() -> None:
"""Deleting all right home should remove everything in the input."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.cursor_position = 0
input.action_delete_right_all()
assert input.cursor_position == 0
assert input.value == ""
Expand Down
5 changes: 5 additions & 0 deletions tests/input/test_input_key_movement_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ async def test_input_right_from_home() -> None:
"""Going right should always land at the next position, if there is one."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.cursor_position = 0
input.action_cursor_right()
assert input.cursor_position == (1 if input.value else 0)

Expand All @@ -60,6 +61,7 @@ async def test_input_left_from_home() -> None:
"""Going left from home should stay put."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.cursor_position = 0
input.action_cursor_left()
assert input.cursor_position == 0

Expand All @@ -77,6 +79,7 @@ async def test_input_left_word_from_home() -> None:
"""Going left one word from the start should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.cursor_position = 0
input.action_cursor_left_word()
assert input.cursor_position == 0

Expand Down Expand Up @@ -118,6 +121,7 @@ async def test_input_right_word_from_home() -> None:
"multi-and-hyphenated": 5,
}
for input in pilot.app.query(Input):
input.cursor_position = 0
input.action_cursor_right_word()
assert input.cursor_position == expected_at[input.id]

Expand Down Expand Up @@ -151,6 +155,7 @@ async def test_input_right_word_to_the_end() -> None:
"multi-and-hyphenated": 7,
}
for input in pilot.app.query(Input):
input.cursor_position = 0
hops = 0
while input.cursor_position < len(input.value):
input.action_cursor_right_word()
Expand Down
Loading