Merge branch 'main' of github.com:willmcgugan/textual into datatable-cell-keys

This commit is contained in:
Darren Burns
2023-01-30 15:51:51 +00:00
20 changed files with 737 additions and 142 deletions

View File

@@ -9,8 +9,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- Added `TreeNode.expand_all` https://github.com/Textualize/textual/issues/1430
- Added `TreeNode.collapse_all` https://github.com/Textualize/textual/issues/1430
- Added `TreeNode.toggle_all` https://github.com/Textualize/textual/issues/1430
- Added the coroutines `Animator.wait_until_complete` and `pilot.wait_for_scheduled_animations` that allow waiting for all current and scheduled animations https://github.com/Textualize/textual/issues/1658
- Added the method `Animator.is_being_animated` that checks if an attribute of an object is being animated or is scheduled for animation
- Added more keyboard actions and related bindings to `Input` https://github.com/Textualize/textual/pull/1676
- Added App.scroll_sensitivity_x and App.scroll_sensitivity_y to adjust how many lines the scroll wheel moves the scroll position https://github.com/Textualize/textual/issues/928
- Added Shift+scroll wheel and ctrl+scroll wheel to scroll horizontally
### Changed
@@ -25,6 +31,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Programmatically setting `overflow_x`/`overflow_y` refreshes the layout correctly https://github.com/Textualize/textual/issues/1616
- Fixed double-paste into `Input` https://github.com/Textualize/textual/issues/1657
- Added a workaround for an apparent Windows Terminal paste issue https://github.com/Textualize/textual/issues/1661
- Fixes issue with renderable width calculation https://github.com/Textualize/textual/issues/1685
## [0.10.1] - 2023-01-20

41
src/textual/_wait.py Normal file
View File

@@ -0,0 +1,41 @@
from asyncio import sleep
from time import process_time, monotonic
SLEEP_GRANULARITY: float = 1 / 50
SLEEP_IDLE: float = SLEEP_GRANULARITY / 2.0
async def wait_for_idle(
min_sleep: float = SLEEP_GRANULARITY, max_sleep: float = 1
) -> None:
"""Wait until the process isn't working very hard.
This will compare wall clock time with process time, if the process time
is not advancing the same as wall clock time it means the process is in a
sleep state or waiting for input.
When the process is idle it suggests that input has been processes and the state
is predictable enough to test.
Args:
min_sleep: Minimum time to wait.
max_sleep: Maximum time to wait.
"""
start_time = monotonic()
while True:
cpu_time = process_time()
# Sleep for a predetermined amount of time
await sleep(SLEEP_GRANULARITY)
# Calculate the wall clock elapsed time and the process elapsed time
cpu_elapsed = process_time() - cpu_time
elapsed_time = monotonic() - start_time
# If we have slept the maximum, we can break
if elapsed_time >= max_sleep:
break
# If we have slept at least the minimum and the cpu elapsed is significantly less
# than wall clock, then we can assume the process has finished working for now
if elapsed_time > min_sleep and cpu_elapsed < SLEEP_IDLE:
break

View File

@@ -68,6 +68,7 @@ from .messages import CallbackType
from .reactive import Reactive
from .renderables.blank import Blank
from .screen import Screen
from ._wait import wait_for_idle
from .widget import AwaitMount, Widget
if TYPE_CHECKING:
@@ -369,6 +370,13 @@ class App(Generic[ReturnType], DOMNode):
self.css_path = css_paths
self._registry: WeakSet[DOMNode] = WeakSet()
# Sensitivity on X is double the sensitivity on Y to account for
# cells being twice as tall as wide
self.scroll_sensitivity_x: float = 4.0
"""Number of columns to scroll in the X direction with wheel or trackpad."""
self.scroll_sensitivity_y: float = 2.0
"""Number of lines to scroll in the Y direction with wheel or trackpad."""
self._installed_screens: WeakValueDictionary[
str, Screen | Callable[[], Screen]
] = WeakValueDictionary()
@@ -796,10 +804,10 @@ class App(Generic[ReturnType], DOMNode):
app = self
driver = app._driver
assert driver is not None
await wait_for_idle(0)
for key in keys:
if key == "_":
print("(pause 50ms)")
await asyncio.sleep(0.05)
continue
elif key.startswith("wait:"):
_, wait_ms = key.split(":")
print(f"(pause {wait_ms}ms)")
@@ -821,15 +829,10 @@ class App(Generic[ReturnType], DOMNode):
print(f"press {key!r} (char={char!r})")
key_event = events.Key(app, key, char)
driver.send_event(key_event)
# TODO: A bit of a fudge - extra sleep after tabbing to help guard against race
# condition between widget-level key handling and app/screen level handling.
# More information here: https://github.com/Textualize/textual/issues/1009
# This conditional sleep can be removed after that issue is resolved.
if key == "tab":
await asyncio.sleep(0.05)
await asyncio.sleep(0.025)
await wait_for_idle(0)
await app._animator.wait_for_idle()
await asyncio.sleep(2 / 100)
await wait_for_idle(0)
@asynccontextmanager
async def run_test(

View File

@@ -6,6 +6,7 @@ import asyncio
from typing import Generic
from .app import App, ReturnType
from ._wait import wait_for_idle
@rich.repr.auto(angular=True)
@@ -33,14 +34,17 @@ class Pilot(Generic[ReturnType]):
if keys:
await self._app._press_keys(keys)
async def pause(self, delay: float = 50 / 1000) -> None:
async def pause(self, delay: float | None = None) -> None:
"""Insert a pause.
Args:
delay: Seconds to pause. Defaults to 50ms.
delay: Seconds to pause, or None to wait for cpu idle.
"""
# These sleep zeros, are to force asyncio to give up a time-slice,
await asyncio.sleep(delay)
if delay is None:
await wait_for_idle(0)
else:
await asyncio.sleep(delay)
async def wait_for_animation(self) -> None:
"""Wait for any current animation to complete."""
@@ -56,4 +60,5 @@ class Pilot(Generic[ReturnType]):
Args:
result: The app result returned by `run` or `run_async`.
"""
await wait_for_idle()
self.app.exit(result)

View File

@@ -1,14 +1,23 @@
from __future__ import annotations
from rich.console import Console, RenderableType
from rich.protocol import rich_cast
def measure(console: Console, renderable: RenderableType, default: int) -> int:
def measure(
console: Console,
renderable: RenderableType,
default: int,
*,
container_width: int | None = None,
) -> int:
"""Measure a rich renderable.
Args:
console: A console object.
renderable: Rich renderable.
default: Default width to use if renderable does not expose dimensions.
container_width: Width of container or None to use console width.
Returns:
Width in cells
@@ -17,6 +26,13 @@ def measure(console: Console, renderable: RenderableType, default: int) -> int:
renderable = rich_cast(renderable)
get_console_width = getattr(renderable, "__rich_measure__", None)
if get_console_width is not None:
render_width = get_console_width(console, console.options).maximum
render_width = get_console_width(
console,
(
console.options
if container_width is None
else console.options.update_width(container_width)
),
).maximum
width = max(0, render_width)
return width

View File

@@ -795,7 +795,9 @@ class Widget(DOMNode):
console = self.app.console
renderable = self._render()
width = measure(console, renderable, container.width)
width = measure(
console, renderable, container.width, container_width=container.width
)
if self.expand:
width = max(container.width, width)
if self.shrink:
@@ -1575,7 +1577,7 @@ class Widget(DOMNode):
"""
return self.scroll_to(
x=self.scroll_target_x - 1,
x=self.scroll_target_x - self.app.scroll_sensitivity_x,
animate=animate,
speed=speed,
duration=duration,
@@ -1607,7 +1609,7 @@ class Widget(DOMNode):
"""
return self.scroll_to(
x=self.scroll_target_x + 1,
x=self.scroll_target_x + self.app.scroll_sensitivity_x,
animate=animate,
speed=speed,
duration=duration,
@@ -1639,7 +1641,7 @@ class Widget(DOMNode):
"""
return self.scroll_to(
y=self.scroll_target_y + 1,
y=self.scroll_target_y + self.app.scroll_sensitivity_y,
animate=animate,
speed=speed,
duration=duration,
@@ -1671,7 +1673,7 @@ class Widget(DOMNode):
"""
return self.scroll_to(
y=self.scroll_target_y - 1,
y=self.scroll_target_y - +self.app.scroll_sensitivity_y,
animate=animate,
speed=speed,
duration=duration,
@@ -2478,15 +2480,25 @@ class Widget(DOMNode):
if self._has_focus_within:
self.app.update_styles(self)
def _on_mouse_scroll_down(self, event) -> None:
if self.allow_vertical_scroll:
if self.scroll_down(animate=False):
event.stop()
def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
if event.ctrl or event.shift:
if self.allow_horizontal_scroll:
if self.scroll_right(animate=False):
event.stop()
else:
if self.allow_vertical_scroll:
if self.scroll_down(animate=False):
event.stop()
def _on_mouse_scroll_up(self, event) -> None:
if self.allow_vertical_scroll:
if self.scroll_up(animate=False):
event.stop()
def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
if event.ctrl or event.shift:
if self.allow_horizontal_scroll:
if self.scroll_left(animate=False):
event.stop()
else:
if self.allow_vertical_scroll:
if self.scroll_up(animate=False):
event.stop()
def _on_scroll_to(self, message: ScrollTo) -> None:
if self._allow_scroll:

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import re
from rich.cells import cell_len, get_character_cell_size
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
from rich.highlighter import Highlighter
@@ -81,12 +83,22 @@ class Input(Widget, can_focus=True):
BINDINGS = [
Binding("left", "cursor_left", "cursor left", show=False),
Binding("ctrl+left", "cursor_left_word", "cursor left word", show=False),
Binding("right", "cursor_right", "cursor right", show=False),
Binding("backspace", "delete_left", "delete left", show=False),
Binding("home", "home", "home", show=False),
Binding("end", "end", "end", show=False),
Binding("ctrl+d", "delete_right", "delete right", show=False),
Binding("ctrl+right", "cursor_right_word", "cursor right word", show=False),
Binding("home,ctrl+a", "home", "home", show=False),
Binding("end,ctrl+e", "end", "end", show=False),
Binding("enter", "submit", "submit", show=False),
Binding("backspace", "delete_left", "delete left", show=False),
Binding(
"ctrl+w", "delete_left_word", "delete left to start of word", show=False
),
Binding("ctrl+u", "delete_left_all", "delete all to the left", show=False),
Binding("delete,ctrl+d", "delete_right", "delete right", show=False),
Binding(
"ctrl+f", "delete_right_word", "delete right to start of word", show=False
),
Binding("ctrl+k", "delete_right_all", "delete all to the right", show=False),
]
COMPONENT_CLASSES = {"input--cursor", "input--placeholder"}
@@ -291,18 +303,42 @@ class Input(Widget, can_focus=True):
self.cursor_position += len(text)
def action_cursor_left(self) -> None:
"""Move the cursor one position to the left."""
self.cursor_position -= 1
def action_cursor_right(self) -> None:
"""Move the cursor one position to the right."""
self.cursor_position += 1
def action_home(self) -> None:
"""Move the cursor to the start of the input."""
self.cursor_position = 0
def action_end(self) -> None:
"""Move the cursor to the end of the input."""
self.cursor_position = len(self.value)
_WORD_START = re.compile(r"(?<=\W)\w")
def action_cursor_left_word(self) -> None:
"""Move the cursor left to the start of a word."""
try:
*_, hit = re.finditer(self._WORD_START, self.value[: self.cursor_position])
except ValueError:
self.cursor_position = 0
else:
self.cursor_position = hit.start()
def action_cursor_right_word(self) -> None:
"""Move the cursor right to the start of a word."""
hit = re.search(self._WORD_START, self.value[self.cursor_position :])
if hit is None:
self.cursor_position = len(self.value)
else:
self.cursor_position += hit.start()
def action_delete_right(self) -> None:
"""Delete one character at the current cursor position."""
value = self.value
delete_position = self.cursor_position
before = value[:delete_position]
@@ -310,7 +346,21 @@ class Input(Widget, can_focus=True):
self.value = f"{before}{after}"
self.cursor_position = delete_position
def action_delete_right_word(self) -> None:
"""Delete the current character and all rightward to the start of the next word."""
after = self.value[self.cursor_position :]
hit = re.search(self._WORD_START, after)
if hit is None:
self.value = self.value[: self.cursor_position]
else:
self.value = f"{self.value[: self.cursor_position]}{after[hit.end()-1 :]}"
def action_delete_right_all(self) -> None:
"""Delete the current character and all characters to the right of the cursor position."""
self.value = self.value[: self.cursor_position]
def action_delete_left(self) -> None:
"""Delete one character to the left of the current cursor position."""
if self.cursor_position <= 0:
# Cursor at the start, so nothing to delete
return
@@ -327,6 +377,25 @@ class Input(Widget, can_focus=True):
self.value = f"{before}{after}"
self.cursor_position = delete_position
def action_delete_left_word(self) -> None:
"""Delete leftward of the cursor position to the start of a word."""
if self.cursor_position <= 0:
return
after = self.value[self.cursor_position :]
try:
*_, hit = re.finditer(self._WORD_START, self.value[: self.cursor_position])
except ValueError:
self.cursor_position = 0
else:
self.cursor_position = hit.start()
self.value = f"{self.value[: self.cursor_position]}{after}"
def action_delete_left_all(self) -> None:
"""Delete all characters to the left of the cursor position."""
if self.cursor_position > 0:
self.value = self.value[self.cursor_position :]
self.cursor_position = 0
async def action_submit(self) -> None:
await self.emit(self.Submitted(self, self.value))

View File

@@ -159,23 +159,63 @@ class TreeNode(Generic[TreeDataType]):
self._allow_expand = allow_expand
self._updates += 1
def expand(self) -> None:
"""Expand a node (show its children)."""
def _expand(self, expand_all: bool) -> None:
"""Mark the node as expanded (its children are shown).
Args:
expand_all: If `True` expand all offspring at all depths.
"""
self._expanded = True
self._updates += 1
if expand_all:
for child in self.children:
child._expand(expand_all)
def expand(self) -> None:
"""Expand the node (show its children)."""
self._expand(False)
self._tree._invalidate()
def collapse(self) -> None:
"""Collapse the node (hide children)."""
def expand_all(self) -> None:
"""Expand the node (show its children) and all those below it."""
self._expand(True)
self._tree._invalidate()
def _collapse(self, collapse_all: bool) -> None:
"""Mark the node as collapsed (its children are hidden).
Args:
collapse_all: If `True` collapse all offspring at all depths.
"""
self._expanded = False
self._updates += 1
if collapse_all:
for child in self.children:
child._collapse(collapse_all)
def collapse(self) -> None:
"""Collapse the node (hide its children)."""
self._collapse(False)
self._tree._invalidate()
def collapse_all(self) -> None:
"""Collapse the node (hide its children) and all those below it."""
self._collapse(True)
self._tree._invalidate()
def toggle(self) -> None:
"""Toggle the expanded state."""
self._expanded = not self._expanded
self._updates += 1
self._tree._invalidate()
"""Toggle the node's expanded state."""
if self._expanded:
self.collapse()
else:
self.expand()
def toggle_all(self) -> None:
"""Toggle the node's expanded state and make all those below it match."""
if self._expanded:
self.collapse_all()
else:
self.expand_all()
@property
def label(self) -> TextType:

View File

@@ -0,0 +1,148 @@
"""Unit tests for Input widget value modification actions."""
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.widgets import Input
TEST_INPUTS: dict[str | None, str] = {
"empty": "",
"multi-no-punctuation": "Curse your sudden but inevitable betrayal",
"multi-punctuation": "We have done the impossible, and that makes us mighty.",
"multi-and-hyphenated": "Long as she does it quiet-like",
}
class InputTester(App[None]):
"""Input widget testing app."""
def compose(self) -> ComposeResult:
for input_id, value in TEST_INPUTS.items():
yield Input(value, id=input_id)
async def test_delete_left_from_home() -> None:
"""Deleting left from home should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_delete_left()
assert input.cursor_position == 0
assert input.value == TEST_INPUTS[input.id]
async def test_delete_left_from_end() -> None:
"""Deleting left from end should remove the last character (if there is one)."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_left()
assert input.cursor_position == len(input.value)
assert input.value == TEST_INPUTS[input.id][:-1]
async def test_delete_left_word_from_home() -> None:
"""Deleting word left from home should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_delete_left_word()
assert input.cursor_position == 0
assert input.value == TEST_INPUTS[input.id]
async def test_delete_left_word_from_end() -> None:
"""Deleting word left from end should remove the expected text."""
async with InputTester().run_test() as pilot:
expected: dict[str | None, str] = {
"empty": "",
"multi-no-punctuation": "Curse your sudden but inevitable ",
"multi-punctuation": "We have done the impossible, and that makes us ",
"multi-and-hyphenated": "Long as she does it quiet-",
}
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_left_word()
assert input.cursor_position == len(input.value)
assert input.value == expected[input.id]
async def test_delete_left_all_from_home() -> None:
"""Deleting all left from home should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_delete_left_all()
assert input.cursor_position == 0
assert input.value == TEST_INPUTS[input.id]
async def test_delete_left_all_from_end() -> None:
"""Deleting all left from end should empty the input value."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_left_all()
assert input.cursor_position == 0
assert input.value == ""
async def test_delete_right_from_home() -> None:
"""Deleting right from home should delete one character (if there is any to delete)."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_delete_right()
assert input.cursor_position == 0
assert input.value == TEST_INPUTS[input.id][1:]
async def test_delete_right_from_end() -> None:
"""Deleting right from end should not change the input's value."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_right()
assert input.cursor_position == len(input.value)
assert input.value == TEST_INPUTS[input.id]
async def test_delete_right_word_from_home() -> None:
"""Deleting word right from home should delete one word (if there is one)."""
async with InputTester().run_test() as pilot:
expected: dict[str | None, str] = {
"empty": "",
"multi-no-punctuation": "your sudden but inevitable betrayal",
"multi-punctuation": "have done the impossible, and that makes us mighty.",
"multi-and-hyphenated": "as she does it quiet-like",
}
for input in pilot.app.query(Input):
input.action_delete_right_word()
assert input.cursor_position == 0
assert input.value == expected[input.id]
async def test_delete_right_word_from_end() -> None:
"""Deleting word right from end should not change the input's value."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_right_word()
assert input.cursor_position == len(input.value)
assert input.value == TEST_INPUTS[input.id]
async def test_delete_right_all_from_home() -> None:
"""Deleting all right home should remove everything in the input."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_delete_right_all()
assert input.cursor_position == 0
assert input.value == ""
async def test_delete_right_all_from_end() -> None:
"""Deleting all right from end should not change the input's value."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_right_all()
assert input.cursor_position == len(input.value)
assert input.value == TEST_INPUTS[input.id]

View File

@@ -0,0 +1,161 @@
"""Unit tests for Input widget position movement actions."""
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.widgets import Input
class InputTester(App[None]):
"""Input widget testing app."""
def compose(self) -> ComposeResult:
for value, input_id in (
("", "empty"),
("Shiny", "single-word"),
("Curse your sudden but inevitable betrayal", "multi-no-punctuation"),
(
"We have done the impossible, and that makes us mighty.",
"multi-punctuation",
),
("Long as she does it quiet-like", "multi-and-hyphenated"),
):
yield Input(value, id=input_id)
async def test_input_home() -> None:
"""Going home should always land at position zero."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_home()
assert input.cursor_position == 0
async def test_input_end() -> None:
"""Going end should always land at the last position."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
assert input.cursor_position == len(input.value)
async def test_input_right_from_home() -> None:
"""Going right should always land at the next position, if there is one."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_cursor_right()
assert input.cursor_position == (1 if input.value else 0)
async def test_input_right_from_end() -> None:
"""Going right should always stay put if doing so from the end."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_cursor_right()
assert input.cursor_position == len(input.value)
async def test_input_left_from_home() -> None:
"""Going left from home should stay put."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_cursor_left()
assert input.cursor_position == 0
async def test_input_left_from_end() -> None:
"""Going left from the end should go back one place, where possible."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_cursor_left()
assert input.cursor_position == (len(input.value) - 1 if input.value else 0)
async def test_input_left_word_from_home() -> None:
"""Going left one word from the start should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_cursor_left_word()
assert input.cursor_position == 0
async def test_input_left_word_from_end() -> None:
"""Going left one word from the end should land correctly.."""
async with InputTester().run_test() as pilot:
expected_at: dict[str | None, int] = {
"empty": 0,
"single-word": 0,
"multi-no-punctuation": 33,
"multi-punctuation": 47,
"multi-and-hyphenated": 26,
}
for input in pilot.app.query(Input):
input.action_end()
input.action_cursor_left_word()
assert input.cursor_position == expected_at[input.id]
async def test_input_right_word_from_home() -> None:
"""Going right one word from the start should land correctly.."""
async with InputTester().run_test() as pilot:
expected_at: dict[str | None, int] = {
"empty": 0,
"single-word": 5,
"multi-no-punctuation": 6,
"multi-punctuation": 3,
"multi-and-hyphenated": 5,
}
for input in pilot.app.query(Input):
input.action_cursor_right_word()
assert input.cursor_position == expected_at[input.id]
async def test_input_right_word_from_end() -> None:
"""Going right one word from the end should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_cursor_right_word()
assert input.cursor_position == len(input.value)
async def test_input_right_word_to_the_end() -> None:
"""Using right-word to get to the end should hop the correct number of times."""
async with InputTester().run_test() as pilot:
expected_hops: dict[str | None, int] = {
"empty": 0,
"single-word": 1,
"multi-no-punctuation": 6,
"multi-punctuation": 10,
"multi-and-hyphenated": 7,
}
for input in pilot.app.query(Input):
hops = 0
while input.cursor_position < len(input.value):
input.action_cursor_right_word()
hops += 1
assert hops == expected_hops[input.id]
async def test_input_left_word_from_the_end() -> None:
"""Using left-word to get home from the end should hop the correct number of times."""
async with InputTester().run_test() as pilot:
expected_hops: dict[str | None, int] = {
"empty": 0,
"single-word": 1,
"multi-no-punctuation": 6,
"multi-punctuation": 10,
"multi-and-hyphenated": 7,
}
for input in pilot.app.query(Input):
input.action_end()
hops = 0
while input.cursor_position:
input.action_cursor_left_word()
hops += 1
assert hops == expected_hops[input.id]
# TODO: more tests.

View File

@@ -31,10 +31,8 @@ async def test_empty_inherited_list_view() -> None:
"""An empty self-populating inherited ListView should work as expected."""
async with ListViewApp().run_test() as pilot:
await pilot.press("tab")
await pilot.pause(2 / 100)
assert pilot.app.query_one(MyListView).index is None
await pilot.press("down")
await pilot.pause(2 / 100)
assert pilot.app.query_one(MyListView).index is None
@@ -42,8 +40,6 @@ async def test_populated_inherited_list_view() -> None:
"""A self-populating inherited ListView should work as normal."""
async with ListViewApp(30).run_test() as pilot:
await pilot.press("tab")
await pilot.pause(2 / 100)
assert pilot.app.query_one(MyListView).index == 0
await pilot.press("down")
await pilot.pause(2 / 100)
assert pilot.app.query_one(MyListView).index == 1

File diff suppressed because one or more lines are too long

View File

@@ -190,6 +190,6 @@ def test_demo(snap_compare):
"""Test the demo app (python -m textual)"""
assert snap_compare(
Path("../../src/textual/demo.py"),
press=["down", "down", "down", "_", "_", "_"],
press=["down", "down", "down"],
terminal_size=(100, 30),
)

View File

@@ -14,7 +14,6 @@ def test_auto_pilot() -> None:
async def auto_pilot(pilot: Pilot) -> None:
await pilot.press("tab", *"foo")
await pilot.pause(1 / 100)
await pilot.exit("bar")
app = TestApp()

View File

@@ -238,7 +238,7 @@ async def test_pressing_alpha_on_app() -> None:
"""Test that pressing the alpha key, when it's bound on the app, results in an action fire."""
async with AppWithMovementKeysBound().run_test() as pilot:
await pilot.press(*AppKeyRecorder.ALPHAS)
await pilot.pause(2 / 100)
await pilot.pause()
assert pilot.app.pressed_keys == [*AppKeyRecorder.ALPHAS]
@@ -246,7 +246,7 @@ async def test_pressing_movement_keys_app() -> None:
"""Test that pressing the movement keys, when they're bound on the app, results in an action fire."""
async with AppWithMovementKeysBound().run_test() as pilot:
await pilot.press(*AppKeyRecorder.ALL_KEYS)
await pilot.pause(2 / 100)
await pilot.pause()
pilot.app.all_recorded()
@@ -284,7 +284,7 @@ async def test_focused_child_widget_with_movement_bindings() -> None:
"""A focused child widget with movement bindings should handle its own actions."""
async with AppWithWidgetWithBindings().run_test() as pilot:
await pilot.press(*AppKeyRecorder.ALL_KEYS)
await pilot.pause(2 / 100)
pilot.app.all_recorded("locally_")
@@ -331,7 +331,7 @@ async def test_focused_child_widget_with_movement_bindings_on_screen() -> None:
"""A focused child widget, with movement bindings in the screen, should trigger screen actions."""
async with AppWithScreenWithBindingsWidgetNoBindings().run_test() as pilot:
await pilot.press(*AppKeyRecorder.ALL_KEYS)
await pilot.pause(2 / 100)
pilot.app.all_recorded("screenly_")
@@ -374,7 +374,7 @@ async def test_contained_focused_child_widget_with_movement_bindings_on_screen()
"""A contained focused child widget, with movement bindings in the screen, should trigger screen actions."""
async with AppWithScreenWithBindingsWrappedWidgetNoBindings().run_test() as pilot:
await pilot.press(*AppKeyRecorder.ALL_KEYS)
await pilot.pause(2 / 100)
pilot.app.all_recorded("screenly_")
@@ -413,7 +413,7 @@ async def test_focused_child_widget_with_movement_bindings_no_inherit() -> None:
"""A focused child widget with movement bindings and inherit_bindings=False should handle its own actions."""
async with AppWithWidgetWithBindingsNoInherit().run_test() as pilot:
await pilot.press(*AppKeyRecorder.ALL_KEYS)
await pilot.pause(2 / 100)
pilot.app.all_recorded("locally_")
@@ -465,7 +465,7 @@ async def test_focused_child_widget_no_inherit_with_movement_bindings_on_screen(
"""A focused child widget, that doesn't inherit bindings, with movement bindings in the screen, should trigger screen actions."""
async with AppWithScreenWithBindingsWidgetNoBindingsNoInherit().run_test() as pilot:
await pilot.press(*AppKeyRecorder.ALL_KEYS)
await pilot.pause(2 / 100)
pilot.app.all_recorded("screenly_")
@@ -520,7 +520,6 @@ async def test_focused_child_widget_no_inherit_empty_bindings_with_movement_bind
"""A focused child widget, that doesn't inherit bindings and sets BINDINGS empty, with movement bindings in the screen, should trigger screen actions."""
async with AppWithScreenWithBindingsWidgetEmptyBindingsNoInherit().run_test() as pilot:
await pilot.press(*AppKeyRecorder.ALL_KEYS)
await pilot.pause(2 / 100)
pilot.app.all_recorded("screenly_")
@@ -602,7 +601,6 @@ async def test_overlapping_priority_bindings() -> None:
"""Test an app stack with overlapping bindings."""
async with PriorityOverlapApp().run_test() as pilot:
await pilot.press(*"0abcdef")
await pilot.pause(2 / 100)
assert pilot.app.pressed_keys == [
"widget_0",
"app_a",

View File

@@ -40,5 +40,4 @@ async def test_toggle_dark_in_action() -> None:
"""It should be possible to toggle dark mode with an action."""
async with OnMountDarkSwitch().run_test() as pilot:
await pilot.press("d")
await pilot.pause(2 / 100)
assert not pilot.app.dark

View File

@@ -16,7 +16,6 @@ async def test_run_test() -> None:
str(pilot) == "<Pilot app=TestApp(title='TestApp', classes={'-dark-mode'})>"
)
await pilot.press("tab", *"foo")
await pilot.pause(1 / 100)
await pilot.exit("bar")
assert app.return_value == "bar"

View File

@@ -0,0 +1,106 @@
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.widgets import Tree
class TreeApp(App[None]):
"""Test tree app."""
def compose(self) -> ComposeResult:
yield Tree("Test")
def on_mount(self) -> None:
tree = self.query_one(Tree)
for n in range(10):
tree.root.add(f"Trunk {n}")
node = tree.root.children[0]
for n in range(10):
node = node.add(str(n))
async def test_tree_node_expand() -> None:
"""Expanding one node should not expand all nodes."""
async with TreeApp().run_test() as pilot:
pilot.app.query_one(Tree).root.expand()
assert pilot.app.query_one(Tree).root.is_expanded is True
check_node = pilot.app.query_one(Tree).root.children[0]
while check_node.children:
assert any(child.is_expanded for child in check_node.children) is False
check_node = check_node.children[0]
async def test_tree_node_expand_all() -> None:
"""Expanding all on a node should expand all child nodes too."""
async with TreeApp().run_test() as pilot:
pilot.app.query_one(Tree).root.expand_all()
assert pilot.app.query_one(Tree).root.is_expanded is True
check_node = pilot.app.query_one(Tree).root.children[0]
while check_node.children:
assert check_node.children[0].is_expanded is True
assert any(child.is_expanded for child in check_node.children[1:]) is False
check_node = check_node.children[0]
async def test_tree_node_collapse() -> None:
"""Collapsing one node should not collapse all nodes."""
async with TreeApp().run_test() as pilot:
pilot.app.query_one(Tree).root.expand_all()
pilot.app.query_one(Tree).root.children[0].collapse()
assert pilot.app.query_one(Tree).root.children[0].is_expanded is False
check_node = pilot.app.query_one(Tree).root.children[0].children[0]
while check_node.children:
assert all(child.is_expanded for child in check_node.children) is True
check_node = check_node.children[0]
async def test_tree_node_collapse_all() -> None:
"""Collapsing all on a node should collapse all child noes too."""
async with TreeApp().run_test() as pilot:
pilot.app.query_one(Tree).root.expand_all()
pilot.app.query_one(Tree).root.children[0].collapse_all()
assert pilot.app.query_one(Tree).root.children[0].is_expanded is False
check_node = pilot.app.query_one(Tree).root.children[0].children[0]
while check_node.children:
assert check_node.children[0].is_expanded is False
assert all(child.is_expanded for child in check_node.children[1:]) is True
check_node = check_node.children[0]
async def test_tree_node_toggle() -> None:
"""Toggling one node should not toggle all nodes."""
async with TreeApp().run_test() as pilot:
assert pilot.app.query_one(Tree).root.is_expanded is False
check_node = pilot.app.query_one(Tree).root.children[0]
while check_node.children:
assert any(child.is_expanded for child in check_node.children) is False
check_node = check_node.children[0]
pilot.app.query_one(Tree).root.toggle()
assert pilot.app.query_one(Tree).root.is_expanded is True
check_node = pilot.app.query_one(Tree).root.children[0]
while check_node.children:
assert any(child.is_expanded for child in check_node.children) is False
check_node = check_node.children[0]
async def test_tree_node_toggle_all() -> None:
"""Toggling all on a node should toggle all child nodes too."""
async with TreeApp().run_test() as pilot:
assert pilot.app.query_one(Tree).root.is_expanded is False
check_node = pilot.app.query_one(Tree).root.children[0]
while check_node.children:
assert any(child.is_expanded for child in check_node.children) is False
check_node = check_node.children[0]
pilot.app.query_one(Tree).root.toggle_all()
assert pilot.app.query_one(Tree).root.is_expanded is True
check_node = pilot.app.query_one(Tree).root.children[0]
while check_node.children:
assert check_node.children[0].is_expanded is True
assert any(child.is_expanded for child in check_node.children[1:]) is False
check_node = check_node.children[0]
pilot.app.query_one(Tree).root.toggle_all()
assert pilot.app.query_one(Tree).root.is_expanded is False
check_node = pilot.app.query_one(Tree).root.children[0]
while check_node.children:
assert any(child.is_expanded for child in check_node.children) is False
check_node = check_node.children[0]

View File

@@ -46,7 +46,6 @@ async def test_tree_node_selected_message() -> None:
"""Selecting a node should result in a selected message being emitted."""
async with TreeApp().run_test() as pilot:
await pilot.press("enter")
await pilot.pause(2 / 100)
assert pilot.app.messages == ["NodeExpanded", "NodeSelected"]
@@ -54,7 +53,6 @@ async def test_tree_node_expanded_message() -> None:
"""Expanding a node should result in an expanded message being emitted."""
async with TreeApp().run_test() as pilot:
await pilot.press("enter")
await pilot.pause(2 / 100)
assert pilot.app.messages == ["NodeExpanded", "NodeSelected"]
@@ -62,7 +60,6 @@ async def test_tree_node_collapsed_message() -> None:
"""Collapsing a node should result in a collapsed message being emitted."""
async with TreeApp().run_test() as pilot:
await pilot.press("enter", "enter")
await pilot.pause(4 / 100)
assert pilot.app.messages == [
"NodeExpanded",
"NodeSelected",
@@ -75,5 +72,4 @@ async def test_tree_node_highlighted_message() -> None:
"""Highlighting a node should result in a highlighted message being emitted."""
async with TreeApp().run_test() as pilot:
await pilot.press("enter", "down")
await pilot.pause(2 / 100)
assert pilot.app.messages == ["NodeExpanded", "NodeSelected", "NodeHighlighted"]