Skip to content

Commit

Permalink
Merge branch 'main' into selection-fix
Browse files Browse the repository at this point in the history
  • Loading branch information
willmcgugan authored Dec 18, 2024
2 parents 50b3677 + d9f8e39 commit 0884282
Show file tree
Hide file tree
Showing 10 changed files with 102 additions and 9 deletions.
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,21 @@ 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 `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

### 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

### Added
Expand All @@ -20,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
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
2 changes: 1 addition & 1 deletion 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
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 0884282

Please sign in to comment.