import asyncio import contextlib import pytest from rich.terminal_theme import DIMMED_MONOKAI, MONOKAI, NIGHT_OWLISH from textual import events from textual.app import App, ComposeResult from textual.command import SimpleCommand from textual.pilot import Pilot, _get_mouse_message_arguments from textual.widgets import Button, Input, Label, Static def test_batch_update(): """Test `batch_update` context manager""" app = App() assert app._batch_count == 0 # Start at zero with app.batch_update(): assert app._batch_count == 1 # Increments in context manager with app.batch_update(): assert app._batch_count == 2 # Nested updates assert app._batch_count == 1 # Exiting decrements assert app._batch_count == 0 # Back to zero class MyApp(App): def compose(self) -> ComposeResult: yield Input() yield Button("Click me!") async def test_hover_update_styles(): app = MyApp(ansi_color=False) async with app.run_test() as pilot: button = app.query_one(Button) assert button.pseudo_classes == { "blur", "can-focus", "dark", "enabled", "first-of-type", "last-of-type", "last-child", "even", "empty", } # Take note of the initial background colour initial_background = button.styles.background await pilot.hover(Button) # We've hovered, so ensure the pseudoclass is present and background changed assert button.pseudo_classes == { "blur", "can-focus", "dark", "enabled", "hover", "first-of-type", "last-of-type", "last-child", "even", "empty", } assert button.styles.background != initial_background def test_setting_title(): app = MyApp() app.title = None assert app.title == "None" app.title = "" assert app.title == "" app.title = 0.125 assert app.title == "0.125" app.title = [True, False, 2] assert app.title == "[True, False, 2]" def test_setting_sub_title(): app = MyApp() app.sub_title = None assert app.sub_title == "None" app.sub_title = "" assert app.sub_title == "" app.sub_title = 0.125 assert app.sub_title == "0.125" app.sub_title = [True, False, 2] assert app.sub_title == "[True, False, 2]" async def test_default_return_code_is_zero(): app = App() async with app.run_test(): app.exit() assert app.return_code == 0 async def test_return_code_is_one_after_crash(): class MyApp(App): def key_p(self): 1 / 0 app = MyApp() with contextlib.suppress(ZeroDivisionError): async with app.run_test() as pilot: await pilot.press("p") assert app.return_code == 1 async def test_set_return_code(): app = App() async with app.run_test(): app.exit(return_code=42) assert app.return_code == 42 def test_no_return_code_before_running(): app = App() assert app.return_code is None async def test_no_return_code_while_running(): app = App() async with app.run_test(): assert app.return_code is None async def test_ansi_theme(): app = App() async with app.run_test(): app.ansi_theme_dark = NIGHT_OWLISH assert app.ansi_theme == NIGHT_OWLISH app.theme = "textual-light" assert app.ansi_theme != NIGHT_OWLISH app.ansi_theme_light = MONOKAI assert app.ansi_theme == MONOKAI # Ensure if we change the dark theme while on light mode, # then change back to dark mode, the dark theme is updated. app.ansi_theme_dark = DIMMED_MONOKAI assert app.ansi_theme == MONOKAI app.theme = "textual-dark" assert app.ansi_theme == DIMMED_MONOKAI async def test_early_exit(): """Test exiting early doesn't cause issues.""" from textual.app import App class AppExit(App): def compose(self): yield Static("Hello") def on_mount(self) -> None: # Exit after creating app self.exit() app = AppExit() async with app.run_test(): pass def test_early_exit_inline(): """Test exiting early in inline mode doesn't break.""" class AppExit(App[None]): def compose(self): yield Static("Hello") def on_mount(self) -> None: # Exit after creating app self.exit() app = AppExit() app.run(inline=True, inline_no_clear=True) async def test_search_with_simple_commands(): """Test search with a list of SimpleCommands and ensure callbacks are invoked.""" called = False def callback(): nonlocal called called = True app = App[None]() commands = [ SimpleCommand("Test Command", callback, "A test command"), SimpleCommand("Another Command", callback, "Another test command"), ] async with app.run_test() as pilot: await app.search_commands(commands) await pilot.press("enter", "enter") assert called async def test_search_with_tuples(): """Test search with a list of tuples and ensure callbacks are invoked. In this case we also have no help text in the tuples. """ called = False def callback(): nonlocal called called = True app = App[None]() commands = [ ("Test Command", callback), ("Another Command", callback), ] async with app.run_test() as pilot: await app.search_commands(commands) await pilot.press("enter", "enter") assert called async def test_search_with_empty_list(): """Test search with an empty command list doesn't crash.""" app = App[None]() async with app.run_test(): await app.search_commands([]) async def raw_click(pilot: Pilot, selector: str, times: int = 1): """A lower level click function that doesn't use the Pilot, and so doesn't bypass the click chain logic in App.on_event.""" app = pilot.app kwargs = _get_mouse_message_arguments(app.query_one(selector)) for _ in range(times): app.post_message(events.MouseDown(**kwargs)) app.post_message(events.MouseUp(**kwargs)) await pilot.pause() @pytest.mark.parametrize("number_of_clicks,final_count", [(1, 1), (2, 3), (3, 6)]) async def test_click_chain_initial_repeated_clicks( number_of_clicks: int, final_count: int ): click_count = 0 class MyApp(App[None]): # Ensure clicks are always within the time threshold CLICK_CHAIN_TIME_THRESHOLD = 1000.0 def compose(self) -> ComposeResult: yield Label("Click me!", id="one") def on_click(self, event: events.Click) -> None: nonlocal click_count print(f"event: {event}") click_count += event.chain async with MyApp().run_test() as pilot: # Clicking the same Label at the same offset creates a double and triple click. for _ in range(number_of_clicks): await raw_click(pilot, "#one") assert click_count == final_count async def test_click_chain_different_offset(): click_count = 0 class MyApp(App[None]): # Ensure clicks are always within the time threshold CLICK_CHAIN_TIME_THRESHOLD = 1000.0 def compose(self) -> ComposeResult: yield Label("One!", id="one") yield Label("Two!", id="two") yield Label("Three!", id="three") def on_click(self, event: events.Click) -> None: nonlocal click_count click_count += event.chain async with MyApp().run_test() as pilot: # Clicking on different offsets in quick-succession doesn't qualify as a double or triple click. await raw_click(pilot, "#one") assert click_count == 1 await raw_click(pilot, "#two") assert click_count == 2 await raw_click(pilot, "#three") assert click_count == 3 async def test_click_chain_offset_changes_mid_chain(): """If we're in the middle of a click chain (e.g. we've double clicked), and the third click comes in at a different offset, that third click should be considered a single click. """ click_count = 0 class MyApp(App[None]): # Ensure clicks are always within the time threshold CLICK_CHAIN_TIME_THRESHOLD = 1000.0 def compose(self) -> ComposeResult: yield Label("Click me!", id="one") yield Label("Another button!", id="two") def on_click(self, event: events.Click) -> None: nonlocal click_count click_count = event.chain async with MyApp().run_test() as pilot: await raw_click(pilot, "#one", times=2) # Double click assert click_count == 2 await raw_click(pilot, "#two") # Single click (because different widget) assert click_count == 1 async def test_click_chain_time_outwith_threshold(): click_count = 0 class MyApp(App[None]): # Intentionally set the threshold to 0.0 to ensure we always exceed it # and can confirm that a click chain is never created CLICK_CHAIN_TIME_THRESHOLD = 0.0 def compose(self) -> ComposeResult: yield Label("Click me!", id="one") def on_click(self, event: events.Click) -> None: nonlocal click_count click_count += event.chain async with MyApp().run_test() as pilot: for i in range(1, 4): # Each click is outwith the time threshold, so a click chain is never created. await raw_click(pilot, "#one") assert click_count == i def test_app_loop() -> None: """Test that App.run accepts a loop argument.""" class MyApp(App[int]): def on_mount(self) -> None: self.exit(42) app = MyApp() result = app.run(loop=asyncio.new_event_loop()) assert result == 42 async def test_app_run_async() -> None: """Check run_async runs without issues.""" class MyApp(App[int]): def on_mount(self) -> None: self.exit(42) app = MyApp() result = await app.run_async() assert result == 42 def test_app_loop_run_after_asyncio_run() -> None: """Test that App.run runs after asyncio.run has run.""" class MyApp(App[int]): def on_mount(self) -> None: self.exit(42) async def amain(): pass asyncio.run(amain()) app = MyApp() result = app.run() assert result == 42