Skip to content

Commit 9d46229

Browse files
authored
Fixes for Input widget cursor visual glitches (#4773)
* Updates to the Input widget cursor * Remove redundant code * Fixing Input widget cursor to match TextArea and remove jankiness * Unit test updates * Fixing more Input tests * Update CHANGELOG * Remove debugging prints
1 parent 3fe08c1 commit 9d46229

File tree

4 files changed

+58
-20
lines changed

4 files changed

+58
-20
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## Unreleased
9+
10+
### Changed
11+
12+
- Input cursor will no longer jump to the end on focus https://github.com/Textualize/textual/pull/4773
13+
14+
### Fixed
15+
16+
- Input cursor blink effect will now restart correctly when any action is performed on the input https://github.com/Textualize/textual/pull/4773
817

918
## [0.75.1] - 2024-08-02
1019

src/textual/widgets/_input.py

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from ..events import Blur, Focus, Mount
2424
from ..geometry import Offset, Size
2525
from ..message import Message
26-
from ..reactive import reactive, var
26+
from ..reactive import Reactive, reactive, var
2727
from ..suggester import Suggester, SuggestionReady
2828
from ..timer import Timer
2929
from ..validation import ValidationResult, Validator
@@ -174,7 +174,7 @@ class Input(Widget, can_focus=True):
174174
cursor_blink = reactive(True, init=False)
175175
value = reactive("", layout=True, init=False)
176176
input_scroll_offset = reactive(0)
177-
cursor_position = reactive(0)
177+
cursor_position: Reactive[int] = reactive(0)
178178
view_position = reactive(0)
179179
placeholder = reactive("")
180180
complete = reactive("")
@@ -335,8 +335,11 @@ def __init__(
335335
elif self.type == "number":
336336
self.validators.append(Number())
337337

338+
self._initial_value = True
339+
"""Indicates if the value has been set for the first time yet."""
338340
if value is not None:
339341
self.value = value
342+
340343
if tooltip is not None:
341344
self.tooltip = tooltip
342345

@@ -391,8 +394,8 @@ def _watch_cursor_blink(self, blink: bool) -> None:
391394
if blink:
392395
self._blink_timer.resume()
393396
else:
397+
self._pause_blink_cycle()
394398
self._cursor_visible = True
395-
self._blink_timer.pause()
396399

397400
@property
398401
def cursor_screen_offset(self) -> Offset:
@@ -412,6 +415,11 @@ def _watch_value(self, value: str) -> None:
412415
)
413416
self.post_message(self.Changed(self, value, validation_result))
414417

418+
# If this is the first time the value has been updated, set the cursor position to the end
419+
if self._initial_value:
420+
self.cursor_position = len(self.value)
421+
self._initial_value = False
422+
415423
def _watch_valid_empty(self) -> None:
416424
"""Repeat validation when valid_empty changes."""
417425
self._watch_value(self.value)
@@ -506,32 +514,25 @@ def _toggle_cursor(self) -> None:
506514
"""Toggle visibility of cursor."""
507515
self._cursor_visible = not self._cursor_visible
508516

509-
def _on_mount(self, _: Mount) -> None:
517+
def _on_mount(self, event: Mount) -> None:
510518
self._blink_timer = self.set_interval(
511519
0.5,
512520
self._toggle_cursor,
513521
pause=not (self.cursor_blink and self.has_focus),
514522
)
515523

516-
def _on_blur(self, _: Blur) -> None:
517-
assert self._blink_timer is not None
518-
self._blink_timer.pause()
524+
def _on_blur(self, event: Blur) -> None:
525+
self._pause_blink_cycle()
519526
if "blur" in self.validate_on:
520527
self.validate(self.value)
521528

522-
def _on_focus(self, _: Focus) -> None:
523-
assert self._blink_timer is not None
524-
self.cursor_position = len(self.value)
525-
if self.cursor_blink:
526-
self._blink_timer.resume()
529+
def _on_focus(self, event: Focus) -> None:
530+
self._restart_blink_cycle()
527531
self.app.cursor_position = self.cursor_screen_offset
528532
self._suggestion = ""
529533

530534
async def _on_key(self, event: events.Key) -> None:
531-
assert self._blink_timer is not None
532-
self._cursor_visible = True
533-
if self.cursor_blink:
534-
self._blink_timer.reset()
535+
self._restart_blink_cycle()
535536

536537
if event.is_printable:
537538
event.stop()
@@ -562,11 +563,29 @@ async def _on_click(self, event: events.Click) -> None:
562563
else:
563564
self.cursor_position = len(self.value)
564565

566+
async def _on_mouse_down(self, event: events.MouseDown) -> None:
567+
self._pause_blink_cycle()
568+
569+
async def _on_mouse_up(self, event: events.MouseUp) -> None:
570+
self._restart_blink_cycle()
571+
565572
async def _on_suggestion_ready(self, event: SuggestionReady) -> None:
566573
"""Handle suggestion messages and set the suggestion when relevant."""
567574
if event.value == self.value:
568575
self._suggestion = event.suggestion
569576

577+
def _restart_blink_cycle(self) -> None:
578+
"""Restart the cursor blink cycle."""
579+
self._cursor_visible = True
580+
if self.cursor_blink and self._blink_timer:
581+
self._blink_timer.reset()
582+
583+
def _pause_blink_cycle(self) -> None:
584+
"""Hide the blinking cursor and pause the blink cycle."""
585+
self._cursor_visible = False
586+
if self.cursor_blink and self._blink_timer:
587+
self._blink_timer.pause()
588+
570589
def insert_text_at_cursor(self, text: str) -> None:
571590
"""Insert new text at the cursor, move the cursor to the end of the new text.
572591
@@ -739,7 +758,8 @@ def action_delete_left_word(self) -> None:
739758
self.cursor_position = 0
740759
else:
741760
self.cursor_position = hit.start()
742-
self.value = f"{self.value[: self.cursor_position]}{after}"
761+
new_value = f"{self.value[: self.cursor_position]}{after}"
762+
self.value = new_value
743763

744764
def action_delete_left_all(self) -> None:
745765
"""Delete all characters to the left of the cursor position."""

tests/input/test_input_key_modification_actions.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ async def test_delete_left_from_home() -> None:
2525
"""Deleting left from home should do nothing."""
2626
async with InputTester().run_test() as pilot:
2727
for input in pilot.app.query(Input):
28+
input.cursor_position = 0
2829
input.action_delete_left()
2930
assert input.cursor_position == 0
3031
assert input.value == TEST_INPUTS[input.id]
@@ -44,6 +45,7 @@ async def test_delete_left_word_from_home() -> None:
4445
"""Deleting word left from home should do nothing."""
4546
async with InputTester().run_test() as pilot:
4647
for input in pilot.app.query(Input):
48+
input.cursor_position = 0
4749
input.action_delete_left_word()
4850
assert input.cursor_position == 0
4951
assert input.value == TEST_INPUTS[input.id]
@@ -68,7 +70,6 @@ async def test_delete_left_word_from_end() -> None:
6870
"multi-and-hyphenated": "Long as she does it quiet-",
6971
}
7072
for input in pilot.app.query(Input):
71-
input.action_end()
7273
input.action_delete_left_word()
7374
assert input.cursor_position == len(input.value)
7475
assert input.value == expected[input.id]
@@ -78,7 +79,6 @@ async def test_password_delete_left_word_from_end() -> None:
7879
"""Deleting word left from end of a password input should delete everything."""
7980
async with InputTester().run_test() as pilot:
8081
for input in pilot.app.query(Input):
81-
input.action_end()
8282
input.password = True
8383
input.action_delete_left_word()
8484
assert input.cursor_position == 0
@@ -89,6 +89,7 @@ async def test_delete_left_all_from_home() -> None:
8989
"""Deleting all left from home should do nothing."""
9090
async with InputTester().run_test() as pilot:
9191
for input in pilot.app.query(Input):
92+
input.cursor_position = 0
9293
input.action_delete_left_all()
9394
assert input.cursor_position == 0
9495
assert input.value == TEST_INPUTS[input.id]
@@ -108,6 +109,7 @@ async def test_delete_right_from_home() -> None:
108109
"""Deleting right from home should delete one character (if there is any to delete)."""
109110
async with InputTester().run_test() as pilot:
110111
for input in pilot.app.query(Input):
112+
input.cursor_position = 0
111113
input.action_delete_right()
112114
assert input.cursor_position == 0
113115
assert input.value == TEST_INPUTS[input.id][1:]
@@ -117,7 +119,6 @@ async def test_delete_right_from_end() -> None:
117119
"""Deleting right from end should not change the input's value."""
118120
async with InputTester().run_test() as pilot:
119121
for input in pilot.app.query(Input):
120-
input.action_end()
121122
input.action_delete_right()
122123
assert input.cursor_position == len(input.value)
123124
assert input.value == TEST_INPUTS[input.id]
@@ -133,6 +134,7 @@ async def test_delete_right_word_from_home() -> None:
133134
"multi-and-hyphenated": "as she does it quiet-like",
134135
}
135136
for input in pilot.app.query(Input):
137+
input.cursor_position = 0
136138
input.action_delete_right_word()
137139
assert input.cursor_position == 0
138140
assert input.value == expected[input.id]
@@ -142,6 +144,7 @@ async def test_password_delete_right_word_from_home() -> None:
142144
"""Deleting word right from home of a password input should delete everything."""
143145
async with InputTester().run_test() as pilot:
144146
for input in pilot.app.query(Input):
147+
input.cursor_position = 0
145148
input.password = True
146149
input.action_delete_right_word()
147150
assert input.cursor_position == 0
@@ -162,6 +165,7 @@ async def test_delete_right_all_from_home() -> None:
162165
"""Deleting all right home should remove everything in the input."""
163166
async with InputTester().run_test() as pilot:
164167
for input in pilot.app.query(Input):
168+
input.cursor_position = 0
165169
input.action_delete_right_all()
166170
assert input.cursor_position == 0
167171
assert input.value == ""

tests/input/test_input_key_movement_actions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ async def test_input_right_from_home() -> None:
4343
"""Going right should always land at the next position, if there is one."""
4444
async with InputTester().run_test() as pilot:
4545
for input in pilot.app.query(Input):
46+
input.cursor_position = 0
4647
input.action_cursor_right()
4748
assert input.cursor_position == (1 if input.value else 0)
4849

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

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

@@ -118,6 +121,7 @@ async def test_input_right_word_from_home() -> None:
118121
"multi-and-hyphenated": 5,
119122
}
120123
for input in pilot.app.query(Input):
124+
input.cursor_position = 0
121125
input.action_cursor_right_word()
122126
assert input.cursor_position == expected_at[input.id]
123127

@@ -151,6 +155,7 @@ async def test_input_right_word_to_the_end() -> None:
151155
"multi-and-hyphenated": 7,
152156
}
153157
for input in pilot.app.query(Input):
158+
input.cursor_position = 0
154159
hops = 0
155160
while input.cursor_position < len(input.value):
156161
input.action_cursor_right_word()

0 commit comments

Comments
 (0)