diff --git a/CHANGELOG.md b/CHANGELOG.md index fc04f9ef4e..a404ce764b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Fixed + +- Fixed `Pilot.click` not working with `times` parameter https://github.com/Textualize/textual/pull/5398 + ### Added -- 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 +- 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 +- - 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 + +### Changed + +- 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 +- Updated `TextArea` and `Input` behavior when there is a selection and the user presses left or right https://github.com/Textualize/textual/pull/5400 ## [1.0.0] - 2024-12-12 @@ -21,7 +31,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `system` boolean to Binding, which hides the binding from the help panel https://github.com/Textualize/textual/pull/5352 - Added support for double/triple/etc clicks via `chain` attribute on `Click` events https://github.com/Textualize/textual/pull/5369 - Added `times` parameter to `Pilot.click` method, for simulating rapid clicks https://github.com/Textualize/textual/pull/5369 - +- Text can now be select using mouse or keyboard in the Input widget https://github.com/Textualize/textual/pull/5340 + ### Changed - Breaking change: Change default quit key to `ctrl+q` https://github.com/Textualize/textual/pull/5352 diff --git a/docs/guide/queries.md b/docs/guide/queries.md index c0ce0be51f..ea64b64d54 100644 --- a/docs/guide/queries.md +++ b/docs/guide/queries.md @@ -1,6 +1,6 @@ # DOM Queries -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). +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). 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! @@ -19,7 +19,7 @@ We could do this with the following line of code: send_button = self.query_one("#send") ``` -This will retrieve a widget with an ID of `send`, if there is exactly one. +This will retrieve the first widget discovered with an ID of `send`. If there are no matching widgets, Textual will raise a [NoMatches][textual.css.query.NoMatches] exception. 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 my_button = self.query_one(Button) ``` +`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. + +If you wish to search the entire DOM, you should call `query_one` on the `App` or `Screen` instance. + +```python +# Search the entire Screen for a widget with an ID of "send-email" +self.screen.query_one("#send-email") +``` + ## Making queries 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. diff --git a/src/textual/app.py b/src/textual/app.py index 6c0c10e8d2..346cfcff0f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -4052,7 +4052,9 @@ def _watch_app_focus(self, focus: bool) -> None: # ...settle focus back on that widget. # Don't scroll the newly focused widget, as this can be quite jarring self.screen.set_focus( - self._last_focused_on_app_blur, scroll_visible=False + self._last_focused_on_app_blur, + scroll_visible=False, + from_app_focus=True, ) except NoScreen: pass diff --git a/src/textual/command.py b/src/textual/command.py index 24ad46c8df..5833477234 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -776,7 +776,7 @@ def compose(self) -> ComposeResult: with Vertical(id="--container"): with Horizontal(id="--input"): yield SearchIcon() - yield CommandInput(placeholder=self._placeholder) + yield CommandInput(placeholder=self._placeholder, select_on_focus=False) if not self.run_on_select: yield Button("\u25b6") with Vertical(id="--results"): diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 82f4ffae77..0bea5504e2 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -437,7 +437,7 @@ def _check_rule( # These shouldn't be used in a cache key _EXCLUDE_PSEUDO_CLASSES_FROM_CACHE: Final[set[str]] = { "first-of-type", - "last-of_type", + "last-of-type", "odd", "even", "focus-within", diff --git a/src/textual/events.py b/src/textual/events.py index b977c1edf4..cf6d5706cc 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -16,10 +16,10 @@ from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Type, TypeVar -from typing_extensions import Self import rich.repr from rich.style import Style +from typing_extensions import Self from textual._types import CallbackType from textual.geometry import Offset, Size @@ -722,8 +722,22 @@ class Focus(Event, bubble=False): - [ ] Bubbles - [ ] Verbose + + Args: + from_app_focus: True if this focus event has been sent because the app itself has + regained focus (via an AppFocus event). False if the focus came from within + the Textual app (e.g. via the user pressing tab or a programmatic setting + of the focused widget). """ + def __init__(self, from_app_focus: bool = False) -> None: + self.from_app_focus = from_app_focus + super().__init__() + + def __rich_repr__(self) -> rich.repr.Result: + yield from super().__rich_repr__() + yield "from_app_focus", self.from_app_focus + class Blur(Event, bubble=False): """Sent when a widget is blurred (un-focussed). diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 473341b7e9..2554bf3b8d 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -442,7 +442,8 @@ async def _post_mouse_events( # the driver works and emits a click event. kwargs = message_arguments if mouse_event_cls is Click: - kwargs["chain"] = chain + kwargs = {**kwargs, "chain": chain} + widget_at, _ = app.get_widget_at(*offset) event = mouse_event_cls(**kwargs) # Bypass event processing in App.on_event. Because App.on_event diff --git a/src/textual/screen.py b/src/textual/screen.py index f120c95cba..b8d79116f2 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -869,12 +869,20 @@ def _update_focus_styles( [widget for widget in widgets if widget._has_focus_within], animate=True ) - def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None: + def set_focus( + self, + widget: Widget | None, + scroll_visible: bool = True, + from_app_focus: bool = False, + ) -> None: """Focus (or un-focus) a widget. A focused widget will receive key events first. Args: widget: Widget to focus, or None to un-focus. scroll_visible: Scroll widget in to view. + from_app_focus: True if this focus is due to the app itself having regained + focus. False if the focus is being set because a widget within the app + regained focus. """ if widget is self.focused: # Widget is already focused @@ -899,7 +907,7 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None: # Change focus self.focused = widget # Send focus event - widget.post_message(events.Focus()) + widget.post_message(events.Focus(from_app_focus=from_app_focus)) focused = widget if scroll_visible: diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 8f343b1df3..463f9e13c8 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -641,7 +641,7 @@ def _on_blur(self, event: Blur) -> None: def _on_focus(self, event: Focus) -> None: self._restart_blink() - if self.select_on_focus: + if self.select_on_focus and not event.from_app_focus: self.selection = Selection(0, len(self.value)) self.app.cursor_position = self.cursor_screen_offset self._suggestion = "" @@ -761,11 +761,14 @@ def action_cursor_left(self, select: bool = False) -> None: Args: select: If `True`, select the text to the left of the cursor. """ + start, end = self.selection if select: - start, end = self.selection self.selection = Selection(start, end - 1) else: - self.cursor_position -= 1 + if self.selection.is_empty: + self.cursor_position -= 1 + else: + self.cursor_position = min(start, end) def action_cursor_right(self, select: bool = False) -> None: """Accept an auto-completion or move the cursor one position to the right. @@ -773,15 +776,18 @@ def action_cursor_right(self, select: bool = False) -> None: Args: select: If `True`, select the text to the right of the cursor. """ + start, end = self.selection if select: - start, end = self.selection self.selection = Selection(start, end + 1) else: if self._cursor_at_end and self._suggestion: self.value = self._suggestion self.cursor_position = len(self.value) else: - self.cursor_position += 1 + if self.selection.is_empty: + self.cursor_position += 1 + else: + self.cursor_position = max(start, end) def action_home(self, select: bool = False) -> None: """Move the cursor to the start of the input. diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index b011d8d8ae..687ef8107d 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1835,10 +1835,16 @@ def action_cursor_left(self, select: bool = False) -> None: If the cursor is at the left edge of the document, try to move it to the end of the previous line. + If text is selected, move the cursor to the start of the selection. + Args: select: If True, select the text while moving. """ - target = self.get_cursor_left_location() + target = ( + self.get_cursor_left_location() + if select or self.selection.is_empty + else min(*self.selection) + ) self.move_cursor(target, select=select) def get_cursor_left_location(self) -> Location: @@ -1854,10 +1860,16 @@ def action_cursor_right(self, select: bool = False) -> None: If the cursor is at the end of a line, attempt to go to the start of the next line. + If text is selected, move the cursor to the end of the selection. + Args: select: If True, select the text while moving. """ - target = self.get_cursor_right_location() + target = ( + self.get_cursor_right_location() + if select or self.selection.is_empty + else max(*self.selection) + ) self.move_cursor(target, select=select) def get_cursor_right_location(self) -> Location: diff --git a/tests/input/test_input_terminal_cursor.py b/tests/input/test_input_terminal_cursor.py index b956a29846..31f1770181 100644 --- a/tests/input/test_input_terminal_cursor.py +++ b/tests/input/test_input_terminal_cursor.py @@ -8,7 +8,9 @@ class InputApp(App): CSS = "Input { padding: 4 8 }" def compose(self) -> ComposeResult: - yield Input("こんにちは!") + # We don't want to select the text on focus, as selected text + # has different interactions with the cursor_left action. + yield Input("こんにちは!", select_on_focus=False) async def test_initial_terminal_cursor_position(): diff --git a/tests/input/test_select_on_focus.py b/tests/input/test_select_on_focus.py new file mode 100644 index 0000000000..d804ac6ee6 --- /dev/null +++ b/tests/input/test_select_on_focus.py @@ -0,0 +1,30 @@ +"""The standard path of selecting text on focus is well covered by snapshot tests.""" + +from textual import events +from textual.app import App, ComposeResult +from textual.widgets import Input +from textual.widgets.input import Selection + + +class InputApp(App[None]): + """An app with an input widget.""" + + def compose(self) -> ComposeResult: + yield Input("Hello, world!") + + +async def test_focus_from_app_focus_does_not_select(): + """When an Input has focused and the *app* is blurred and then focused (e.g. by pressing + alt+tab or focusing another terminal pane), then the content of the Input should not be + fully selected when `Input.select_on_focus=True`. + """ + async with InputApp().run_test() as pilot: + input_widget = pilot.app.query_one(Input) + input_widget.focus() + input_widget.selection = Selection.cursor(0) + assert input_widget.selection == Selection.cursor(0) + pilot.app.post_message(events.AppBlur()) + await pilot.pause() + pilot.app.post_message(events.AppFocus()) + await pilot.pause() + assert input_widget.selection == Selection.cursor(0) diff --git a/tests/test_pilot.py b/tests/test_pilot.py index 008d711389..a44e827a74 100644 --- a/tests/test_pilot.py +++ b/tests/test_pilot.py @@ -1,8 +1,10 @@ from string import punctuation +from typing import Type import pytest from textual import events, work +from textual._on import on from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Center, Middle @@ -424,3 +426,27 @@ def on_button_pressed(self): assert not pressed await pilot.click(button) assert pressed + + +@pytest.mark.parametrize("times", [1, 2, 3]) +async def test_click_times(times: int): + """Test that Pilot.click() can be called with a `times` argument.""" + + events_received: list[Type[events.Event]] = [] + + class TestApp(App[None]): + def compose(self) -> ComposeResult: + yield Label("Click counter") + + @on(events.Click) + @on(events.MouseDown) + @on(events.MouseUp) + def on_label_clicked(self, event: events.Event): + events_received.append(event.__class__) + + app = TestApp() + async with app.run_test() as pilot: + await pilot.click(Label, times=times) + assert ( + events_received == [events.MouseDown, events.MouseUp, events.Click] * times + )