mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' of github.com:willmcgugan/textual into datatable-cell-keys
This commit is contained in:
@@ -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
41
src/textual/_wait.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
148
tests/input/test_input_key_modification_actions.py
Normal file
148
tests/input/test_input_key_modification_actions.py
Normal 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]
|
||||
161
tests/input/test_input_key_movement_actions.py
Normal file
161
tests/input/test_input_key_movement_actions.py
Normal 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.
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
106
tests/tree/test_tree_expand_etc.py
Normal file
106
tests/tree/test_tree_expand_etc.py
Normal 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]
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user