Skip to content

Commit

Permalink
Merge branch 'main' into select-experiment
Browse files Browse the repository at this point in the history
  • Loading branch information
willmcgugan authored Dec 18, 2024
2 parents 4ba155b + 86e9353 commit 8301c1c
Show file tree
Hide file tree
Showing 13 changed files with 140 additions and 19 deletions.
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 11 additions & 2 deletions docs/guide/queries.md
Original file line number Diff line number Diff line change
@@ -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!

Expand All @@ -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.
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/textual/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
2 changes: 1 addition & 1 deletion src/textual/css/stylesheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion src/textual/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion src/textual/pilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
16 changes: 11 additions & 5 deletions src/textual/widgets/_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -761,27 +761,33 @@ 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.
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.
Expand Down
16 changes: 14 additions & 2 deletions src/textual/widgets/_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion tests/input/test_input_terminal_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
30 changes: 30 additions & 0 deletions tests/input/test_select_on_focus.py
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 26 additions & 0 deletions tests/test_pilot.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
)

0 comments on commit 8301c1c

Please sign in to comment.