Merge branch 'main' into doc-improvements

This commit is contained in:
Rodrigo Girão Serrão
2023-01-30 15:35:43 +00:00
23 changed files with 927 additions and 155 deletions

View File

@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.11.0] - Unreleased
### 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
- Breaking change: `TreeNode` can no longer be imported from `textual.widgets`; it is now available via `from textual.widgets.tree import TreeNode`. https://github.com/Textualize/textual/pull/1637
@@ -15,9 +26,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed stuck screen https://github.com/Textualize/textual/issues/1632
- Fixed relative units in `grid-rows` and `grid-columns` being computed with respect to the wrong dimension https://github.com/Textualize/textual/issues/1406
- Fixed bug with animations that were triggered back to back, where the second one wouldn't start https://github.com/Textualize/textual/issues/1372
- Fixed bug with animations that were scheduled where all but the first would be skipped https://github.com/Textualize/textual/issues/1372
- 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

View File

@@ -21,6 +21,9 @@ else: # pragma: no cover
if TYPE_CHECKING:
from textual.app import App
AnimationKey = tuple[int, str]
"""Animation keys are the id of the object and the attribute being animated."""
EasingFunction = Callable[[float], float]
@@ -166,10 +169,19 @@ class BoundAnimator:
class Animator:
"""An object to manage updates to a given attribute over a period of time."""
"""An object to manage updates to a given attribute over a period of time.
Attrs:
_animations: Dictionary that maps animation keys to the corresponding animation
instances.
_scheduled: Keys corresponding to animations that have been scheduled but not yet
started.
app: The app that owns the animator object.
"""
def __init__(self, app: App, frames_per_second: int = 60) -> None:
self._animations: dict[tuple[object, str], Animation] = {}
self._animations: dict[AnimationKey, Animation] = {}
self._scheduled: set[AnimationKey] = set()
self.app = app
self._timer = Timer(
app,
@@ -179,11 +191,15 @@ class Animator:
callback=self,
pause=True,
)
# Flag if no animations are currently taking place.
self._idle_event = asyncio.Event()
# Flag if no animations are currently taking place and none are scheduled.
self._complete_event = asyncio.Event()
async def start(self) -> None:
"""Start the animator task."""
self._idle_event.set()
self._complete_event.set()
self._timer.start()
async def stop(self) -> None:
@@ -194,11 +210,17 @@ class Animator:
pass
finally:
self._idle_event.set()
self._complete_event.set()
def bind(self, obj: object) -> BoundAnimator:
"""Bind the animator to a given objects."""
"""Bind the animator to a given object."""
return BoundAnimator(self, obj)
def is_being_animated(self, obj: object, attribute: str) -> bool:
"""Does the object/attribute pair have an ongoing or scheduled animation?"""
key = (id(obj), attribute)
return key in self._animations or key in self._scheduled
def animate(
self,
obj: object,
@@ -237,6 +259,8 @@ class Animator:
on_complete=on_complete,
)
if delay:
self._scheduled.add((id(obj), attribute))
self._complete_event.clear()
self.app.set_timer(delay, animate_callback)
else:
animate_callback()
@@ -273,13 +297,14 @@ class Animator:
duration is None and speed is not None
), "An Animation should have a duration OR a speed"
animation_key = (id(obj), attribute)
self._scheduled.discard(animation_key)
if final_value is ...:
final_value = value
start_time = self._get_time()
animation_key = (id(obj), attribute)
easing_function = EASING[easing] if isinstance(easing, str) else easing
animation: Animation | None = None
@@ -342,11 +367,14 @@ class Animator:
self._animations[animation_key] = animation
self._timer.resume()
self._idle_event.clear()
self._complete_event.clear()
async def __call__(self) -> None:
if not self._animations:
self._timer.pause()
self._idle_event.set()
if not self._scheduled:
self._complete_event.set()
else:
animation_time = self._get_time()
animation_keys = list(self._animations.keys())
@@ -368,3 +396,7 @@ class Animator:
async def wait_for_idle(self) -> None:
"""Wait for any animations to complete."""
await self._idle_event.wait()
async def wait_until_complete(self) -> None:
"""Wait for any current and scheduled animations to complete."""
await self._complete_event.wait()

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,11 +804,10 @@ class App(Generic[ReturnType], DOMNode):
app = self
driver = app._driver
assert driver is not None
await asyncio.sleep(0.02)
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)")
@@ -822,14 +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 closed.
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 wait_for_idle(0)
@asynccontextmanager
async def run_test(

View File

@@ -449,6 +449,8 @@ class Stylesheet:
get_new_render_rule = new_render_rules.get
if animate:
animator = node.app.animator
base = node.styles.base
for key in modified_rule_keys:
# Get old and new render rules
old_render_value = get_current_render_rule(key)
@@ -456,13 +458,18 @@ class Stylesheet:
# Get new rule value (may be None)
new_value = rules.get(key)
# Check if this can / should be animated
if is_animatable(key) and new_render_value != old_render_value:
# Check if this can / should be animated. It doesn't suffice to check
# if the current and target values are different because a previous
# animation may have been scheduled but may have not started yet.
if is_animatable(key) and (
new_render_value != old_render_value
or animator.is_being_animated(base, key)
):
transition = new_styles._get_transition(key)
if transition is not None:
duration, easing, delay = transition
node.app.animator.animate(
node.styles.base,
animator.animate(
base,
key,
new_render_value,
final_value=new_value,

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,23 +34,31 @@ 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 animation to complete."""
"""Wait for any current animation to complete."""
await self._app.animator.wait_for_idle()
async def wait_for_scheduled_animations(self) -> None:
"""Wait for any current and scheduled animations to complete."""
await self._app.animator.wait_until_complete()
async def exit(self, result: ReturnType) -> None:
"""Exit the app with the given result.
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,6 +1,8 @@
from __future__ import annotations
from typing import ClassVar
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
@@ -56,23 +58,39 @@ class Input(Widget, can_focus=True):
BINDINGS: ClassVar[list[BindingType]] = [
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("ctrl+right", "cursor_right_word", "cursor right word", 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("home,ctrl+a", "home", "home", show=False),
Binding("end,ctrl+e", "end", "end", show=False),
Binding("delete,ctrl+d", "delete_right", "delete right", show=False),
Binding("enter", "submit", "submit", 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(
"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),
]
"""
| Key(s) | Description |
| :- | :- |
| left | Move the cursor left. |
| ctrl+left | Move the cursor one word to the left. |
| right | Move the cursor right. |
| ctrl+right | Move the cursor one word to the right. |
| backspace | Delete the character to the left of the cursor. |
| home | Go to the beginning of the input. |
| end | Go to the end of the input. |
| ctrl+d | Delete the character to the right of the cursor. |
| home,ctrl+a | Go to the beginning of the input. |
| end,ctrl+e | Go to the end of the input. |
| delete,ctrl+d | Delete the character to the right of the cursor. |
| enter | Submit the current value of the input. |
| ctrl+w | Delete the word to the left of the cursor. |
| ctrl+u | Delete everything to the left of the cursor. |
| ctrl+f | Delete the word to the right of the cursor. |
| ctrl+k | Delete everything to the right of the cursor. |
"""
COMPONENT_CLASSES: ClassVar[set[str]] = {"input--cursor", "input--placeholder"}
@@ -341,18 +359,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]
@@ -360,7 +402,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
@@ -377,5 +433,24 @@ 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

@@ -9,7 +9,7 @@ class AnimApp(App):
#foo {
height: 1;
}
"""
def compose(self) -> ComposeResult:
@@ -37,3 +37,124 @@ async def test_animate_height() -> None:
assert elapsed >= 0.5
# Check the height reached the maximum
assert static.styles.height.value == 100
async def test_scheduling_animation() -> None:
"""Test that scheduling an animation works."""
app = AnimApp()
delay = 0.1
async with app.run_test() as pilot:
styles = app.query_one(Static).styles
styles.background = "black"
styles.animate("background", "white", delay=delay, duration=0)
await pilot.pause(0.9 * delay)
assert styles.background.rgb == (0, 0, 0) # Still black
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (255, 255, 255)
async def test_wait_for_current_animations() -> None:
"""Test that we can wait only for the current animations taking place."""
app = AnimApp()
delay = 10
async with app.run_test() as pilot:
styles = app.query_one(Static).styles
styles.animate("height", 100, duration=0.1)
start = perf_counter()
styles.animate("height", 200, duration=0.1, delay=delay)
# Wait for the first animation to finish
await pilot.wait_for_animation()
elapsed = perf_counter() - start
assert elapsed < (delay / 2)
async def test_wait_for_current_and_scheduled_animations() -> None:
"""Test that we can wait for current and scheduled animations."""
app = AnimApp()
async with app.run_test() as pilot:
styles = app.query_one(Static).styles
start = perf_counter()
styles.animate("height", 50, duration=0.01)
styles.animate("background", "black", duration=0.01, delay=0.05)
await pilot.wait_for_scheduled_animations()
elapsed = perf_counter() - start
assert elapsed >= 0.06
assert styles.background.rgb == (0, 0, 0)
async def test_reverse_animations() -> None:
"""Test that you can create reverse animations.
Regression test for #1372 https://github.com/Textualize/textual/issues/1372
"""
app = AnimApp()
async with app.run_test() as pilot:
static = app.query_one(Static)
styles = static.styles
# Starting point.
styles.background = "black"
assert styles.background.rgb == (0, 0, 0)
# First, make sure we can go from black to white and back, step by step.
styles.animate("background", "white", duration=0.01)
await pilot.wait_for_animation()
assert styles.background.rgb == (255, 255, 255)
styles.animate("background", "black", duration=0.01)
await pilot.wait_for_animation()
assert styles.background.rgb == (0, 0, 0)
# Now, the actual test is to make sure we go back to black if creating both at once.
styles.animate("background", "white", duration=0.01)
styles.animate("background", "black", duration=0.01)
await pilot.wait_for_animation()
assert styles.background.rgb == (0, 0, 0)
async def test_schedule_reverse_animations() -> None:
"""Test that you can schedule reverse animations.
Regression test for #1372 https://github.com/Textualize/textual/issues/1372
"""
app = AnimApp()
async with app.run_test() as pilot:
static = app.query_one(Static)
styles = static.styles
# Starting point.
styles.background = "black"
assert styles.background.rgb == (0, 0, 0)
# First, make sure we can go from black to white and back, step by step.
styles.animate("background", "white", delay=0.01, duration=0.01)
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (255, 255, 255)
styles.animate("background", "black", delay=0.01, duration=0.01)
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (0, 0, 0)
# Now, the actual test is to make sure we go back to black if scheduling both at once.
styles.animate("background", "white", delay=0.01, duration=0.01)
await pilot.pause(0.005)
styles.animate("background", "black", delay=0.01, duration=0.01)
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (0, 0, 0)

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

@@ -45,7 +45,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"]
@@ -53,7 +52,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"]
@@ -61,7 +59,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(2 / 100)
assert pilot.app.messages == [
"NodeExpanded",
"NodeSelected",
@@ -74,5 +71,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"]