from __future__ import annotations from dataclasses import dataclass import pytest from textual import on from textual._on import OnDecoratorError from textual.app import App, ComposeResult from textual.events import Enter, Leave from textual.message import Message from textual.widget import Widget from textual.widgets import Button, TabbedContent, TabPane async def test_on_button_pressed() -> None: """Test handlers with @on decorator.""" pressed: list[str] = [] class ButtonApp(App): def compose(self) -> ComposeResult: yield Button("OK", id="ok") yield Button("Cancel", classes="exit cancel") yield Button("Quit", classes="exit quit") @on(Button.Pressed, "#ok") def ok(self): pressed.append("ok") @on(Button.Pressed, ".exit") def exit(self): pressed.append("exit") @on(Button.Pressed, ".exit.quit") def _(self): pressed.append("quit") def on_button_pressed(self): pressed.append("default") app = ButtonApp() async with app.run_test() as pilot: await pilot.press("enter", "tab", "enter", "tab", "enter") await pilot.pause() assert pressed == [ "ok", # Matched ok first "default", # on_button_pressed matched everything "exit", # Cancel button, matches exit "default", # on_button_pressed matched everything "exit", # Quit button pressed, matched exit and _ "quit", # Matched previous button "default", # on_button_pressed matched everything ] async def test_on_inheritance() -> None: """Test on decorator and inheritance.""" pressed: list[str] = [] class MyWidget(Widget): def compose(self) -> ComposeResult: yield Button("OK", id="ok") # Also called @on(Button.Pressed, "#ok") def ok(self): pressed.append("MyWidget.ok base") class DerivedWidget(MyWidget): # Should be called first @on(Button.Pressed, "#ok") def ok(self): pressed.append("MyWidget.ok derived") class ButtonApp(App): def compose(self) -> ComposeResult: yield DerivedWidget() app = ButtonApp() async with app.run_test() as pilot: await pilot.press("tab", "enter") expected = ["MyWidget.ok derived", "MyWidget.ok base"] assert pressed == expected def test_on_bad_selector() -> None: """Check bad selectors raise an error.""" with pytest.raises(OnDecoratorError): @on(Button.Pressed, "@") def foo(): pass def test_on_no_control() -> None: """Check messages with no 'control' attribute raise an error.""" class CustomMessage(Message): pass with pytest.raises(OnDecoratorError): @on(CustomMessage, "#foo") def foo(): pass def test_on_attribute_not_listed() -> None: """Check `on` checks if the attribute is in ALLOW_SELECTOR_MATCH.""" class CustomMessage(Message): pass with pytest.raises(OnDecoratorError): @on(CustomMessage, foo="bar") def foo(): pass async def test_on_arbitrary_attributes() -> None: log: list[str] = [] class OnArbitraryAttributesApp(App[None]): def compose(self) -> ComposeResult: with TabbedContent(): yield TabPane("One", id="one") yield TabPane("Two", id="two") yield TabPane("Three", id="three") def on_mount(self) -> None: self.query_one(TabbedContent).add_class("tabs") @on(TabbedContent.TabActivated, pane="#one") def one(self) -> None: log.append("one") @on(TabbedContent.TabActivated, pane="#two") def two(self) -> None: log.append("two") app = OnArbitraryAttributesApp() async with app.run_test() as pilot: await pilot.press("tab", "right", "right") assert log == ["one", "two"] class MessageSender(Widget): @dataclass class Parent(Message): sender: MessageSender @property def control(self) -> MessageSender: return self.sender class Child(Parent): pass def post_parent(self) -> None: self.post_message(self.Parent(self)) def post_child(self) -> None: self.post_message(self.Child(self)) async def test_fire_on_inherited_message() -> None: """Handlers should fire when descendant messages are posted.""" posted: list[str] = [] class InheritTestApp(App[None]): def compose(self) -> ComposeResult: yield MessageSender() @on(MessageSender.Parent) def catch_parent(self) -> None: posted.append("parent") @on(MessageSender.Child) def catch_child(self) -> None: posted.append("child") def on_mount(self) -> None: self.query_one(MessageSender).post_parent() self.query_one(MessageSender).post_child() async with InheritTestApp().run_test(): pass assert posted == ["parent", "child", "parent"] async def test_fire_inherited_on_single_handler() -> None: """Test having parent/child messages on a single handler.""" posted: list[str] = [] class InheritTestApp(App[None]): def compose(self) -> ComposeResult: yield MessageSender() @on(MessageSender.Parent) @on(MessageSender.Child) def catch_either(self, event: MessageSender.Parent) -> None: posted.append(f"either {event.__class__.__name__}") def on_mount(self) -> None: self.query_one(MessageSender).post_parent() self.query_one(MessageSender).post_child() async with InheritTestApp().run_test(): pass assert posted == ["either Parent", "either Child"] async def test_fire_inherited_on_single_handler_multi_selector() -> None: """Test having parent/child messages on a single handler but with different selectors.""" posted: list[str] = [] class InheritTestApp(App[None]): def compose(self) -> ComposeResult: yield MessageSender(classes="a b") @on(MessageSender.Parent, ".y") @on(MessageSender.Child, ".y") @on(MessageSender.Parent, ".a.b") @on(MessageSender.Child, ".a.b") @on(MessageSender.Parent, ".a") @on(MessageSender.Child, ".a") @on(MessageSender.Parent, ".b") @on(MessageSender.Child, ".b") @on(MessageSender.Parent, ".x") @on(MessageSender.Child, ".x") def catch_either(self, event: MessageSender.Parent) -> None: posted.append(f"either {event.__class__.__name__}") @on(MessageSender.Child, ".a, .x") def catch_selector_list_one_miss(self, event: MessageSender.Parent) -> None: posted.append(f"selector list one miss {event.__class__.__name__}") @on(MessageSender.Child, ".a, .b") def catch_selector_list_two_hits(self, event: MessageSender.Parent) -> None: posted.append(f"selector list two hits {event.__class__.__name__}") @on(MessageSender.Child, ".a.b") def catch_selector_combined_hits(self, event: MessageSender.Parent) -> None: posted.append(f"combined hits {event.__class__.__name__}") @on(MessageSender.Child, ".a.x") def catch_selector_combined_miss(self, event: MessageSender.Parent) -> None: posted.append(f"combined miss {event.__class__.__name__}") def on_mount(self) -> None: self.query_one(MessageSender).post_parent() self.query_one(MessageSender).post_child() async with InheritTestApp().run_test(): pass assert posted == [ "either Parent", "either Child", "selector list one miss Child", "selector list two hits Child", "combined hits Child", ] async def test_fire_inherited_and_on_methods() -> None: posted: list[str] = [] class OnAndOnTestApp(App[None]): def compose(self) -> ComposeResult: yield MessageSender() def on_message_sender_parent(self) -> None: posted.append("on_message_sender_parent") @on(MessageSender.Parent) def catch_parent(self) -> None: posted.append("catch_parent") def on_message_sender_child(self) -> None: posted.append("on_message_sender_child") @on(MessageSender.Child) def catch_child(self) -> None: posted.append("catch_child") def on_mount(self) -> None: self.query_one(MessageSender).post_parent() self.query_one(MessageSender).post_child() async with OnAndOnTestApp().run_test(): pass assert posted == [ "catch_parent", "on_message_sender_parent", "catch_child", "catch_parent", "on_message_sender_child", ] class MixinMessageSender(Widget): class Parent(Message): pass class JustSomeRandomMixin: pass class Child(JustSomeRandomMixin, Parent): pass def post_parent(self) -> None: self.post_message(self.Parent()) def post_child(self) -> None: self.post_message(self.Child()) async def test_fire_on_inherited_message_plus_mixins() -> None: """Handlers should fire when descendant messages are posted, without mixins messing things up.""" posted: list[str] = [] class InheritTestApp(App[None]): def compose(self) -> ComposeResult: yield MixinMessageSender() @on(MixinMessageSender.Parent) def catch_parent(self) -> None: posted.append("parent") @on(MixinMessageSender.Child) def catch_child(self) -> None: posted.append("child") def on_mount(self) -> None: self.query_one(MixinMessageSender).post_parent() self.query_one(MixinMessageSender).post_child() async with InheritTestApp().run_test(): pass assert posted == ["parent", "child", "parent"] async def test_on_with_enter_and_leave_events(): class EnterLeaveApp(App): messages = [] def compose(self) -> ComposeResult: yield Button("OK") @on(Enter, "Button") @on(Leave, "Button") def record(self, event: Enter | Leave) -> None: self.messages.append(event.__class__.__name__) app = EnterLeaveApp() async with app.run_test() as pilot: expected_messages = [] await pilot.hover(Button) expected_messages.append("Enter") assert app.messages == expected_messages await pilot.hover(Button, offset=(0, 20)) expected_messages.append("Leave") assert app.messages == expected_messages