Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add TextArea.Changed and TextArea.SelectionChanged messages #3442

Merged
merged 6 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

- `OutOfBounds` exception to be raised by `Pilot` https://github.com/Textualize/textual/pull/3360
- Added `Input.clear` method https://github.com/Textualize/textual/pull/3430
- Added `TextArea.SelectionChanged` and `TextArea.Changed` messages https://github.com/Textualize/textual/pull/3442

### Changed

Expand Down
5 changes: 5 additions & 0 deletions docs/widgets/text_area.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,11 @@ If you notice some highlights are missing after registering a language, the issu
| `match_cursor_bracket` | `bool` | `True` | Enable/disable highlighting matching brackets under cursor. |
| `cursor_blink` | `bool` | `True` | Enable/disable blinking of the cursor when the widget has focus. |

## Messages

- [TextArea.Changed][textual.widgets._text_area.TextArea.Changed]
- [TextArea.SelectionChanged][textual.widgets._text_area.TextArea.SelectionChanged]

## Bindings

The `TextArea` widget defines the following bindings:
Expand Down
3 changes: 2 additions & 1 deletion src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1218,7 +1218,8 @@ async def run_test(
or None to auto-detect.
tooltips: Enable tooltips when testing.
notifications: Enable notifications when testing.
message_hook: An optional callback that will called with every message going through the app.
message_hook: An optional callback that will be called each time any message arrives at any
message pump in the app.
"""
from .pilot import Pilot

Expand Down
40 changes: 38 additions & 2 deletions src/textual/widgets/_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from textual._cells import cell_len
from textual._types import Literal, Protocol, runtime_checkable
from textual.binding import Binding
from textual.events import MouseEvent
from textual.events import Message, MouseEvent
from textual.geometry import Offset, Region, Size, Spacing, clamp
from textual.reactive import Reactive, reactive
from textual.scroll_view import ScrollView
Expand Down Expand Up @@ -203,7 +203,9 @@ class TextArea(ScrollView, can_focus=True):
Syntax highlighting is only possible when the `language` attribute is set.
"""

selection: Reactive[Selection] = reactive(Selection(), always_update=True)
selection: Reactive[Selection] = reactive(
Selection(), always_update=True, init=False
)
"""The selection start and end locations (zero-based line_index, offset).

This represents the cursor location and the current selection.
Expand Down Expand Up @@ -237,6 +239,37 @@ class TextArea(ScrollView, can_focus=True):
"""Indicates where the cursor is in the blink cycle. If it's currently
not visible due to blinking, this is False."""

@dataclass
class Changed(Message):
"""Posted when the content inside the TextArea changes.

Handle this message using the `on` decorator - `@on(TextArea.Changed)`
or a method named `on_text_area_changed`.
"""

text_area: TextArea
"""The `text_area` that sent this message."""

@property
def control(self) -> TextArea:
"""The `TextArea` that sent this message."""
return self.text_area

@dataclass
class SelectionChanged(Message):
"""Posted when the selection changes.

This includes when the cursor moves or when text is selected."""

selection: Selection
"""The new selection."""
text_area: TextArea
"""The `text_area` that sent this message."""

@property
def control(self) -> TextArea:
return self.text_area

def __init__(
self,
text: str = "",
Expand Down Expand Up @@ -377,6 +410,8 @@ def _watch_selection(self, selection: Selection) -> None:
if match_row in range(*self._visible_line_indices):
self.refresh_lines(match_row)

self.post_message(self.SelectionChanged(selection, self))

def find_matching_bracket(
self, bracket: str, search_from: Location
) -> Location | None:
Expand Down Expand Up @@ -917,6 +952,7 @@ def edit(self, edit: Edit) -> Any:
self._refresh_size()
edit.after(self)
self._build_highlight_map()
self.post_message(self.Changed(self))
return result

async def _on_key(self, event: events.Key) -> None:
Expand Down
91 changes: 91 additions & 0 deletions tests/text_area/test_messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from typing import List

from textual import on
from textual.app import App, ComposeResult
from textual.events import Event
from textual.message import Message
from textual.widgets import TextArea


class TextAreaApp(App):
def __init__(self):
super().__init__()
self.messages = []

@on(TextArea.Changed)
@on(TextArea.SelectionChanged)
def message_received(self, message: Message):
self.messages.append(message)

def compose(self) -> ComposeResult:
yield TextArea("123")


def get_changed_messages(messages: List[Event]) -> List[TextArea.Changed]:
return [message for message in messages if isinstance(message, TextArea.Changed)]


def get_selection_changed_messages(
messages: List[Event],
) -> List[TextArea.SelectionChanged]:
return [
message
for message in messages
if isinstance(message, TextArea.SelectionChanged)
]


async def test_changed_message_edit_via_api():
app = TextAreaApp()
async with app.run_test() as pilot:
text_area = app.query_one(TextArea)
assert get_changed_messages(app.messages) == []

text_area.insert("A")
await pilot.pause()

assert get_changed_messages(app.messages) == [TextArea.Changed(text_area)]
assert get_selection_changed_messages(app.messages) == [
TextArea.SelectionChanged(text_area.selection, text_area)
]


async def test_changed_message_via_typing():
app = TextAreaApp()
async with app.run_test() as pilot:
text_area = app.query_one(TextArea)
assert get_changed_messages(app.messages) == []

await pilot.press("a")

assert get_changed_messages(app.messages) == [TextArea.Changed(text_area)]
assert get_selection_changed_messages(app.messages) == [
TextArea.SelectionChanged(text_area.selection, text_area)
]


async def test_selection_changed_via_api():
app = TextAreaApp()
async with app.run_test() as pilot:
text_area = app.query_one(TextArea)
assert get_selection_changed_messages(app.messages) == []

text_area.cursor_location = (0, 1)
await pilot.pause()

assert get_selection_changed_messages(app.messages) == [
TextArea.SelectionChanged(text_area.selection, text_area)
]


async def test_selection_changed_via_typing():
app = TextAreaApp()
async with app.run_test() as pilot:
text_area = app.query_one(TextArea)
assert get_selection_changed_messages(app.messages) == []

await pilot.press("a")

assert get_selection_changed_messages(app.messages) == [
TextArea.SelectionChanged(text_area.selection, text_area)
]
Loading