Skip to content

Commit 8301c1c

Browse files
authored
Merge branch 'main' into select-experiment
2 parents 4ba155b + 86e9353 commit 8301c1c

13 files changed

+140
-19
lines changed

CHANGELOG.md

+13-2
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
77

88
## Unreleased
99

10+
### Fixed
11+
12+
- Fixed `Pilot.click` not working with `times` parameter https://github.com/Textualize/textual/pull/5398
13+
1014
### Added
1115

12-
- Added `Select.type_to_search` which allows you to type to move the cursor to a matching option https://github.com/Textualize/textual/pull/5403
16+
- Added `from_app_focus` to `Focus` event to indicate if a widget is being focused because the app itself has regained focus or not https://github.com/Textualize/textual/pull/5379
17+
- - Added `Select.type_to_search` which allows you to type to move the cursor to a matching option https://github.com/Textualize/textual/pull/5403
18+
19+
### Changed
20+
21+
- The content of an `Input` will now only be automatically selected when the widget is focused by the user, not when the app itself has regained focus (similar to web browsers). https://github.com/Textualize/textual/pull/5379
22+
- Updated `TextArea` and `Input` behavior when there is a selection and the user presses left or right https://github.com/Textualize/textual/pull/5400
1323

1424

1525
## [1.0.0] - 2024-12-12
@@ -21,7 +31,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2131
- Added `system` boolean to Binding, which hides the binding from the help panel https://github.com/Textualize/textual/pull/5352
2232
- Added support for double/triple/etc clicks via `chain` attribute on `Click` events https://github.com/Textualize/textual/pull/5369
2333
- Added `times` parameter to `Pilot.click` method, for simulating rapid clicks https://github.com/Textualize/textual/pull/5369
24-
34+
- Text can now be select using mouse or keyboard in the Input widget https://github.com/Textualize/textual/pull/5340
35+
2536
### Changed
2637

2738
- Breaking change: Change default quit key to `ctrl+q` https://github.com/Textualize/textual/pull/5352

docs/guide/queries.md

+11-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# DOM Queries
22

3-
In the previous chapter we introduced the [DOM](../guide/CSS.md#the-dom) which is how Textual apps keep track of widgets. We saw how you can apply styles to the DOM with CSS [selectors](./CSS.md#selectors).
3+
In the [CSS chapter](./CSS.md) we introduced the [DOM](../guide/CSS.md#the-dom) which is how Textual apps keep track of widgets. We saw how you can apply styles to the DOM with CSS [selectors](./CSS.md#selectors).
44

55
Selectors are a very useful idea and can do more than apply styles. We can also find widgets in Python code with selectors, and make updates to widgets in a simple expressive way. Let's look at how!
66

@@ -19,7 +19,7 @@ We could do this with the following line of code:
1919
send_button = self.query_one("#send")
2020
```
2121

22-
This will retrieve a widget with an ID of `send`, if there is exactly one.
22+
This will retrieve the first widget discovered with an ID of `send`.
2323
If there are no matching widgets, Textual will raise a [NoMatches][textual.css.query.NoMatches] exception.
2424

2525
You can also add a second parameter for the expected type, which will ensure that you get the type you are expecting.
@@ -41,6 +41,15 @@ For instance, the following would return a `Button` instance (assuming there is
4141
my_button = self.query_one(Button)
4242
```
4343

44+
`query_one` searches the DOM *below* the widget it is called on, so if you call `query_one` on a widget, it will only find widgets that are descendants of that widget.
45+
46+
If you wish to search the entire DOM, you should call `query_one` on the `App` or `Screen` instance.
47+
48+
```python
49+
# Search the entire Screen for a widget with an ID of "send-email"
50+
self.screen.query_one("#send-email")
51+
```
52+
4453
## Making queries
4554

4655
Apps and widgets also have a [query][textual.dom.DOMNode.query] method which finds (or queries) widgets. This method returns a [DOMQuery][textual.css.query.DOMQuery] object which is a list-like container of widgets.

src/textual/app.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -4052,7 +4052,9 @@ def _watch_app_focus(self, focus: bool) -> None:
40524052
# ...settle focus back on that widget.
40534053
# Don't scroll the newly focused widget, as this can be quite jarring
40544054
self.screen.set_focus(
4055-
self._last_focused_on_app_blur, scroll_visible=False
4055+
self._last_focused_on_app_blur,
4056+
scroll_visible=False,
4057+
from_app_focus=True,
40564058
)
40574059
except NoScreen:
40584060
pass

src/textual/command.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,7 @@ def compose(self) -> ComposeResult:
776776
with Vertical(id="--container"):
777777
with Horizontal(id="--input"):
778778
yield SearchIcon()
779-
yield CommandInput(placeholder=self._placeholder)
779+
yield CommandInput(placeholder=self._placeholder, select_on_focus=False)
780780
if not self.run_on_select:
781781
yield Button("\u25b6")
782782
with Vertical(id="--results"):

src/textual/css/stylesheet.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ def _check_rule(
437437
# These shouldn't be used in a cache key
438438
_EXCLUDE_PSEUDO_CLASSES_FROM_CACHE: Final[set[str]] = {
439439
"first-of-type",
440-
"last-of_type",
440+
"last-of-type",
441441
"odd",
442442
"even",
443443
"focus-within",

src/textual/events.py

+15-1
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616
from dataclasses import dataclass
1717
from pathlib import Path
1818
from typing import TYPE_CHECKING, Type, TypeVar
19-
from typing_extensions import Self
2019

2120
import rich.repr
2221
from rich.style import Style
22+
from typing_extensions import Self
2323

2424
from textual._types import CallbackType
2525
from textual.geometry import Offset, Size
@@ -722,8 +722,22 @@ class Focus(Event, bubble=False):
722722
723723
- [ ] Bubbles
724724
- [ ] Verbose
725+
726+
Args:
727+
from_app_focus: True if this focus event has been sent because the app itself has
728+
regained focus (via an AppFocus event). False if the focus came from within
729+
the Textual app (e.g. via the user pressing tab or a programmatic setting
730+
of the focused widget).
725731
"""
726732

733+
def __init__(self, from_app_focus: bool = False) -> None:
734+
self.from_app_focus = from_app_focus
735+
super().__init__()
736+
737+
def __rich_repr__(self) -> rich.repr.Result:
738+
yield from super().__rich_repr__()
739+
yield "from_app_focus", self.from_app_focus
740+
727741

728742
class Blur(Event, bubble=False):
729743
"""Sent when a widget is blurred (un-focussed).

src/textual/pilot.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,8 @@ async def _post_mouse_events(
442442
# the driver works and emits a click event.
443443
kwargs = message_arguments
444444
if mouse_event_cls is Click:
445-
kwargs["chain"] = chain
445+
kwargs = {**kwargs, "chain": chain}
446+
446447
widget_at, _ = app.get_widget_at(*offset)
447448
event = mouse_event_cls(**kwargs)
448449
# Bypass event processing in App.on_event. Because App.on_event

src/textual/screen.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -869,12 +869,20 @@ def _update_focus_styles(
869869
[widget for widget in widgets if widget._has_focus_within], animate=True
870870
)
871871

872-
def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None:
872+
def set_focus(
873+
self,
874+
widget: Widget | None,
875+
scroll_visible: bool = True,
876+
from_app_focus: bool = False,
877+
) -> None:
873878
"""Focus (or un-focus) a widget. A focused widget will receive key events first.
874879
875880
Args:
876881
widget: Widget to focus, or None to un-focus.
877882
scroll_visible: Scroll widget in to view.
883+
from_app_focus: True if this focus is due to the app itself having regained
884+
focus. False if the focus is being set because a widget within the app
885+
regained focus.
878886
"""
879887
if widget is self.focused:
880888
# Widget is already focused
@@ -899,7 +907,7 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None:
899907
# Change focus
900908
self.focused = widget
901909
# Send focus event
902-
widget.post_message(events.Focus())
910+
widget.post_message(events.Focus(from_app_focus=from_app_focus))
903911
focused = widget
904912

905913
if scroll_visible:

src/textual/widgets/_input.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,7 @@ def _on_blur(self, event: Blur) -> None:
641641

642642
def _on_focus(self, event: Focus) -> None:
643643
self._restart_blink()
644-
if self.select_on_focus:
644+
if self.select_on_focus and not event.from_app_focus:
645645
self.selection = Selection(0, len(self.value))
646646
self.app.cursor_position = self.cursor_screen_offset
647647
self._suggestion = ""
@@ -761,27 +761,33 @@ def action_cursor_left(self, select: bool = False) -> None:
761761
Args:
762762
select: If `True`, select the text to the left of the cursor.
763763
"""
764+
start, end = self.selection
764765
if select:
765-
start, end = self.selection
766766
self.selection = Selection(start, end - 1)
767767
else:
768-
self.cursor_position -= 1
768+
if self.selection.is_empty:
769+
self.cursor_position -= 1
770+
else:
771+
self.cursor_position = min(start, end)
769772

770773
def action_cursor_right(self, select: bool = False) -> None:
771774
"""Accept an auto-completion or move the cursor one position to the right.
772775
773776
Args:
774777
select: If `True`, select the text to the right of the cursor.
775778
"""
779+
start, end = self.selection
776780
if select:
777-
start, end = self.selection
778781
self.selection = Selection(start, end + 1)
779782
else:
780783
if self._cursor_at_end and self._suggestion:
781784
self.value = self._suggestion
782785
self.cursor_position = len(self.value)
783786
else:
784-
self.cursor_position += 1
787+
if self.selection.is_empty:
788+
self.cursor_position += 1
789+
else:
790+
self.cursor_position = max(start, end)
785791

786792
def action_home(self, select: bool = False) -> None:
787793
"""Move the cursor to the start of the input.

src/textual/widgets/_text_area.py

+14-2
Original file line numberDiff line numberDiff line change
@@ -1835,10 +1835,16 @@ def action_cursor_left(self, select: bool = False) -> None:
18351835
If the cursor is at the left edge of the document, try to move it to
18361836
the end of the previous line.
18371837
1838+
If text is selected, move the cursor to the start of the selection.
1839+
18381840
Args:
18391841
select: If True, select the text while moving.
18401842
"""
1841-
target = self.get_cursor_left_location()
1843+
target = (
1844+
self.get_cursor_left_location()
1845+
if select or self.selection.is_empty
1846+
else min(*self.selection)
1847+
)
18421848
self.move_cursor(target, select=select)
18431849

18441850
def get_cursor_left_location(self) -> Location:
@@ -1854,10 +1860,16 @@ def action_cursor_right(self, select: bool = False) -> None:
18541860
18551861
If the cursor is at the end of a line, attempt to go to the start of the next line.
18561862
1863+
If text is selected, move the cursor to the end of the selection.
1864+
18571865
Args:
18581866
select: If True, select the text while moving.
18591867
"""
1860-
target = self.get_cursor_right_location()
1868+
target = (
1869+
self.get_cursor_right_location()
1870+
if select or self.selection.is_empty
1871+
else max(*self.selection)
1872+
)
18611873
self.move_cursor(target, select=select)
18621874

18631875
def get_cursor_right_location(self) -> Location:

tests/input/test_input_terminal_cursor.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ class InputApp(App):
88
CSS = "Input { padding: 4 8 }"
99

1010
def compose(self) -> ComposeResult:
11-
yield Input("こんにちは!")
11+
# We don't want to select the text on focus, as selected text
12+
# has different interactions with the cursor_left action.
13+
yield Input("こんにちは!", select_on_focus=False)
1214

1315

1416
async def test_initial_terminal_cursor_position():

tests/input/test_select_on_focus.py

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""The standard path of selecting text on focus is well covered by snapshot tests."""
2+
3+
from textual import events
4+
from textual.app import App, ComposeResult
5+
from textual.widgets import Input
6+
from textual.widgets.input import Selection
7+
8+
9+
class InputApp(App[None]):
10+
"""An app with an input widget."""
11+
12+
def compose(self) -> ComposeResult:
13+
yield Input("Hello, world!")
14+
15+
16+
async def test_focus_from_app_focus_does_not_select():
17+
"""When an Input has focused and the *app* is blurred and then focused (e.g. by pressing
18+
alt+tab or focusing another terminal pane), then the content of the Input should not be
19+
fully selected when `Input.select_on_focus=True`.
20+
"""
21+
async with InputApp().run_test() as pilot:
22+
input_widget = pilot.app.query_one(Input)
23+
input_widget.focus()
24+
input_widget.selection = Selection.cursor(0)
25+
assert input_widget.selection == Selection.cursor(0)
26+
pilot.app.post_message(events.AppBlur())
27+
await pilot.pause()
28+
pilot.app.post_message(events.AppFocus())
29+
await pilot.pause()
30+
assert input_widget.selection == Selection.cursor(0)

tests/test_pilot.py

+26
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from string import punctuation
2+
from typing import Type
23

34
import pytest
45

56
from textual import events, work
7+
from textual._on import on
68
from textual.app import App, ComposeResult
79
from textual.binding import Binding
810
from textual.containers import Center, Middle
@@ -424,3 +426,27 @@ def on_button_pressed(self):
424426
assert not pressed
425427
await pilot.click(button)
426428
assert pressed
429+
430+
431+
@pytest.mark.parametrize("times", [1, 2, 3])
432+
async def test_click_times(times: int):
433+
"""Test that Pilot.click() can be called with a `times` argument."""
434+
435+
events_received: list[Type[events.Event]] = []
436+
437+
class TestApp(App[None]):
438+
def compose(self) -> ComposeResult:
439+
yield Label("Click counter")
440+
441+
@on(events.Click)
442+
@on(events.MouseDown)
443+
@on(events.MouseUp)
444+
def on_label_clicked(self, event: events.Event):
445+
events_received.append(event.__class__)
446+
447+
app = TestApp()
448+
async with app.run_test() as pilot:
449+
await pilot.click(Label, times=times)
450+
assert (
451+
events_received == [events.MouseDown, events.MouseUp, events.Click] * times
452+
)

0 commit comments

Comments
 (0)