textual/tests/test_app.py
Darren Burns 3c120c0ab0
Chaining click events (double/triple click etc) (#5369)
* Add comment about Click events

* Remove unused `App._hover_effects_timer`

* Add missing annotation

* Add missing type annotation

* Add `App._click_chain_timer`

* Add support for click chaining (double click, triple click, etc.)

* Create `App.CLICK_CHAIN_TIME_THRESHOLD` for controlling click chain timing

* Some tests for chained clicks

* Test changes [no ci]

* Have Pilot send only MouseUp and MouseDown, and let Textual generate clicks itself [no ci]

* Fix DataTable click tet [no ci]

* Rename Click.count -> Click.chain

* Test fixes

* Enhance raw_click function documentation in test_app.py to clarify its purpose and behavior

* Refactor imports in events.py: remove Self from typing and import from typing_extensions

* Remove unnecessary pause in test_datatable_click_cell_cursor

* Remove debug print statements and unnecessary pause in App class; add on_mount method to LazyApp for better lifecycle management in tests

* Remove debugging prints

* Add support for double and triple clicks in testing guide

* Add a note about double and triple clicks to the docs

* Turn off formatter for a section of code, and make it 3.8 compatible

* Update changelog [no ci]

* Simplify by removing an unecessary variable in `Pilot.click`

* Remove debugging code

* Add target-version py38 to ruff config in pyproject.toml, and remove formatter comments

* Document timing of click chains

* Pilot.double_click and Pilot.triple_click
2024-12-11 16:25:42 +00:00

342 lines
9.5 KiB
Python

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",
"even",
}
# 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",
"even",
}
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