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:
95
tests/css/test_programmatic_style_changes.py
Normal file
95
tests/css/test_programmatic_style_changes.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import pytest
|
||||
|
||||
from textual.app import App
|
||||
from textual.containers import Grid
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Label
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"style, value",
|
||||
[
|
||||
("grid_size_rows", 3),
|
||||
("grid_size_columns", 3),
|
||||
("grid_gutter_vertical", 4),
|
||||
("grid_gutter_horizontal", 4),
|
||||
("grid_rows", "1fr 3fr"),
|
||||
("grid_columns", "1fr 3fr"),
|
||||
],
|
||||
)
|
||||
async def test_programmatic_style_change_updates_children(style: str, value: object):
|
||||
"""Regression test for #1607 https://github.com/Textualize/textual/issues/1607
|
||||
|
||||
Some programmatic style changes to a widget were not updating the layout of the
|
||||
children widgets, which seemed to be happening when the style change did not affect
|
||||
the size of the widget but did affect the layout of the children.
|
||||
|
||||
This test, in particular, checks the attributes that _should_ affect the size of the
|
||||
children widgets.
|
||||
"""
|
||||
|
||||
class MyApp(App[None]):
|
||||
CSS = """
|
||||
Grid { grid-size: 2 2; }
|
||||
Label { width: 100%; height: 100%; }
|
||||
"""
|
||||
|
||||
def compose(self):
|
||||
yield Grid(
|
||||
Label("one"),
|
||||
Label("two"),
|
||||
Label("three"),
|
||||
Label("four"),
|
||||
)
|
||||
|
||||
app = MyApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
sizes = [(lbl.size.width, lbl.size.height) for lbl in app.screen.query(Label)]
|
||||
|
||||
setattr(app.query_one(Grid).styles, style, value)
|
||||
await pilot.pause()
|
||||
|
||||
assert sizes != [
|
||||
(lbl.size.width, lbl.size.height) for lbl in app.screen.query(Label)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"style, value",
|
||||
[
|
||||
("align_horizontal", "right"),
|
||||
("align_vertical", "bottom"),
|
||||
("align", ("right", "bottom")),
|
||||
],
|
||||
)
|
||||
async def test_programmatic_align_change_updates_children_position(
|
||||
style: str, value: str
|
||||
):
|
||||
"""Regression test for #1607 for the align(_xxx) styles.
|
||||
|
||||
See https://github.com/Textualize/textual/issues/1607.
|
||||
"""
|
||||
|
||||
class MyApp(App[None]):
|
||||
CSS = "Grid { grid-size: 2 2; }"
|
||||
|
||||
def compose(self):
|
||||
yield Grid(
|
||||
Label("one"),
|
||||
Label("two"),
|
||||
Label("three"),
|
||||
Label("four"),
|
||||
)
|
||||
|
||||
app = MyApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
offsets = [(lbl.region.x, lbl.region.y) for lbl in app.screen.query(Label)]
|
||||
|
||||
setattr(app.query_one(Grid).styles, style, value)
|
||||
await pilot.pause()
|
||||
|
||||
assert offsets != [
|
||||
(lbl.region.x, lbl.region.y) for lbl in app.screen.query(Label)
|
||||
]
|
||||
@@ -66,6 +66,17 @@ async def test_delete_left_word_from_end() -> None:
|
||||
assert input.value == expected[input.id]
|
||||
|
||||
|
||||
async def test_password_delete_left_word_from_end() -> None:
|
||||
"""Deleting word left from end of a password input should delete everything."""
|
||||
async with InputTester().run_test() as pilot:
|
||||
for input in pilot.app.query(Input):
|
||||
input.action_end()
|
||||
input.password = True
|
||||
input.action_delete_left_word()
|
||||
assert input.cursor_position == 0
|
||||
assert input.value == ""
|
||||
|
||||
|
||||
async def test_delete_left_all_from_home() -> None:
|
||||
"""Deleting all left from home should do nothing."""
|
||||
async with InputTester().run_test() as pilot:
|
||||
@@ -119,6 +130,16 @@ async def test_delete_right_word_from_home() -> None:
|
||||
assert input.value == expected[input.id]
|
||||
|
||||
|
||||
async def test_password_delete_right_word_from_home() -> None:
|
||||
"""Deleting word right from home of a password input should delete everything."""
|
||||
async with InputTester().run_test() as pilot:
|
||||
for input in pilot.app.query(Input):
|
||||
input.password = True
|
||||
input.action_delete_right_word()
|
||||
assert input.cursor_position == 0
|
||||
assert input.value == ""
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -97,6 +97,16 @@ async def test_input_left_word_from_end() -> None:
|
||||
assert input.cursor_position == expected_at[input.id]
|
||||
|
||||
|
||||
async def test_password_input_left_word_from_end() -> None:
|
||||
"""Going left one word from the end in a password field should land at home."""
|
||||
async with InputTester().run_test() as pilot:
|
||||
for input in pilot.app.query(Input):
|
||||
input.action_end()
|
||||
input.password = True
|
||||
input.action_cursor_left_word()
|
||||
assert input.cursor_position == 0
|
||||
|
||||
|
||||
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:
|
||||
@@ -112,6 +122,15 @@ async def test_input_right_word_from_home() -> None:
|
||||
assert input.cursor_position == expected_at[input.id]
|
||||
|
||||
|
||||
async def test_password_input_right_word_from_home() -> None:
|
||||
"""Going right one word from the start of a password input should go to the end."""
|
||||
async with InputTester().run_test() as pilot:
|
||||
for input in pilot.app.query(Input):
|
||||
input.password = True
|
||||
input.action_cursor_right_word()
|
||||
assert input.cursor_position == len(input.value)
|
||||
|
||||
|
||||
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:
|
||||
|
||||
File diff suppressed because one or more lines are too long
22
tests/snapshot_tests/snapshot_apps/auto_width_input.py
Normal file
22
tests/snapshot_tests/snapshot_apps/auto_width_input.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Header, Footer, Label, Input
|
||||
|
||||
|
||||
class InputWidthAutoApp(App[None]):
|
||||
|
||||
CSS = """
|
||||
Input.auto {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Input(placeholder="This has auto width", classes="auto")
|
||||
yield Footer()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
InputWidthAutoApp().run()
|
||||
45
tests/snapshot_tests/snapshot_apps/label_widths.py
Normal file
45
tests/snapshot_tests/snapshot_apps/label_widths.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from textual.app import App
|
||||
from textual.widgets import Label, Static
|
||||
from rich.panel import Panel
|
||||
|
||||
|
||||
class LabelWrap(App):
|
||||
CSS = """Screen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#l_data {
|
||||
border: blank;
|
||||
background: lightgray;
|
||||
}
|
||||
|
||||
#s_data {
|
||||
border: blank;
|
||||
background: lightgreen;
|
||||
}
|
||||
|
||||
#p_data {
|
||||
border: blank;
|
||||
background: lightgray;
|
||||
}"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.data = (
|
||||
"Apple Banana Cherry Mango Fig Guava Pineapple:"
|
||||
"Dragon Unicorn Centaur Phoenix Chimera Castle"
|
||||
)
|
||||
|
||||
def compose(self):
|
||||
yield Label(self.data, id="l_data")
|
||||
yield Static(self.data, id="s_data")
|
||||
yield Label(Panel(self.data), id="p_data")
|
||||
|
||||
def on_mount(self):
|
||||
self.dark = False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LabelWrap()
|
||||
app.run()
|
||||
@@ -0,0 +1,29 @@
|
||||
from textual.app import App
|
||||
from textual.containers import Grid
|
||||
from textual.widgets import Label
|
||||
|
||||
|
||||
class ProgrammaticScrollbarGutterChange(App[None]):
|
||||
CSS = """
|
||||
Grid { grid-size: 2 2; scrollbar-size: 5 5; }
|
||||
Label { width: 100%; height: 100%; background: red; }
|
||||
"""
|
||||
|
||||
def compose(self):
|
||||
yield Grid(
|
||||
Label("one"),
|
||||
Label("two"),
|
||||
Label("three"),
|
||||
Label("four"),
|
||||
)
|
||||
|
||||
def on_key(self, event):
|
||||
if event.key == "s":
|
||||
self.query_one(Grid).styles.scrollbar_gutter = "stable"
|
||||
|
||||
|
||||
app = ProgrammaticScrollbarGutterChange()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app().run()
|
||||
@@ -179,6 +179,16 @@ def test_nested_auto_heights(snap_compare):
|
||||
assert snap_compare("snapshot_apps/nested_auto_heights.py", press=["1", "2", "_"])
|
||||
|
||||
|
||||
def test_programmatic_scrollbar_gutter_change(snap_compare):
|
||||
"""Regression test for #1607 https://github.com/Textualize/textual/issues/1607
|
||||
|
||||
See also tests/css/test_programmatic_style_changes.py for other related regression tests.
|
||||
"""
|
||||
assert snap_compare(
|
||||
"snapshot_apps/programmatic_scrollbar_gutter_change.py", press=["s"]
|
||||
)
|
||||
|
||||
|
||||
# --- Other ---
|
||||
|
||||
|
||||
@@ -193,3 +203,14 @@ def test_demo(snap_compare):
|
||||
press=["down", "down", "down"],
|
||||
terminal_size=(100, 30),
|
||||
)
|
||||
|
||||
|
||||
def test_label_widths(snap_compare):
|
||||
"""Test renderable widths are calculate correctly."""
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "label_widths.py")
|
||||
|
||||
|
||||
def test_auto_width_input(snap_compare):
|
||||
assert snap_compare(
|
||||
SNAPSHOT_APPS_DIR / "auto_width_input.py", press=["tab", *"Hello"]
|
||||
)
|
||||
|
||||
@@ -153,8 +153,8 @@ async def test_schedule_reverse_animations() -> None:
|
||||
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)
|
||||
styles.animate("background", "white", delay=0.05, duration=0.01)
|
||||
await pilot.pause()
|
||||
styles.animate("background", "black", delay=0.05, duration=0.01)
|
||||
await pilot.wait_for_scheduled_animations()
|
||||
assert styles.background.rgb == (0, 0, 0)
|
||||
|
||||
18
tests/test_paste.py
Normal file
18
tests/test_paste.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from textual.app import App
|
||||
from textual import events
|
||||
|
||||
|
||||
async def test_paste_app():
|
||||
paste_events = []
|
||||
|
||||
class PasteApp(App):
|
||||
def on_paste(self, event):
|
||||
paste_events.append(event)
|
||||
|
||||
app = PasteApp()
|
||||
async with app.run_test() as pilot:
|
||||
await app.post_message(events.Paste(sender=app, text="Hello"))
|
||||
await pilot.pause(0)
|
||||
|
||||
assert len(paste_events) == 1
|
||||
assert paste_events[0].text == "Hello"
|
||||
@@ -83,6 +83,14 @@ def test_adjust_cell_length():
|
||||
)
|
||||
|
||||
|
||||
def test_extend_cell_length():
|
||||
strip = Strip([Segment("foo"), Segment("bar")])
|
||||
assert strip.extend_cell_length(3).text == "foobar"
|
||||
assert strip.extend_cell_length(6).text == "foobar"
|
||||
assert strip.extend_cell_length(7).text == "foobar "
|
||||
assert strip.extend_cell_length(9).text == "foobar "
|
||||
|
||||
|
||||
def test_simplify():
|
||||
assert Strip([Segment("foo"), Segment("bar")]).simplify() == Strip(
|
||||
[Segment("foobar")]
|
||||
|
||||
@@ -10,7 +10,7 @@ from textual.geometry import Region, Size
|
||||
from textual.strip import Strip
|
||||
|
||||
|
||||
def _extract_content(lines: list[list[Segment]]):
|
||||
def _extract_content(lines: list[Strip]) -> list[str]:
|
||||
"""Extract the text content from lines."""
|
||||
content = ["".join(segment.text for segment in line) for line in lines]
|
||||
return content
|
||||
@@ -28,9 +28,9 @@ def test_set_dirty():
|
||||
def test_no_styles():
|
||||
"""Test that empty style returns the content un-altered"""
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
styles = Styles()
|
||||
cache = StylesCache()
|
||||
@@ -54,9 +54,9 @@ def test_no_styles():
|
||||
|
||||
def test_border():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
styles = Styles()
|
||||
styles.border = ("heavy", "white")
|
||||
@@ -85,9 +85,9 @@ def test_border():
|
||||
|
||||
def test_padding():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
styles = Styles()
|
||||
styles.padding = 1
|
||||
@@ -116,9 +116,9 @@ def test_padding():
|
||||
|
||||
def test_padding_border():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
styles = Styles()
|
||||
styles.padding = 1
|
||||
@@ -150,9 +150,9 @@ def test_padding_border():
|
||||
|
||||
def test_outline():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
styles = Styles()
|
||||
styles.outline = ("heavy", "white")
|
||||
@@ -177,9 +177,9 @@ def test_outline():
|
||||
|
||||
def test_crop():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
styles = Styles()
|
||||
styles.padding = 1
|
||||
@@ -203,17 +203,17 @@ def test_crop():
|
||||
assert text_content == expected_text
|
||||
|
||||
|
||||
def test_dirty_cache():
|
||||
def test_dirty_cache() -> None:
|
||||
"""Check that we only render content once or if it has been marked as dirty."""
|
||||
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
rendered_lines: list[int] = []
|
||||
|
||||
def get_content_line(y: int) -> list[Segment]:
|
||||
def get_content_line(y: int) -> Strip:
|
||||
rendered_lines.append(y)
|
||||
return content[y]
|
||||
|
||||
@@ -227,11 +227,13 @@ def test_dirty_cache():
|
||||
Color.parse("blue"),
|
||||
Color.parse("green"),
|
||||
get_content_line,
|
||||
Size(3, 3),
|
||||
)
|
||||
assert rendered_lines == [0, 1, 2]
|
||||
del rendered_lines[:]
|
||||
|
||||
text_content = _extract_content(lines)
|
||||
|
||||
expected_text = [
|
||||
"┏━━━━━┓",
|
||||
"┃ ┃",
|
||||
|
||||
73
tests/tree/test_tree_clearing.py
Normal file
73
tests/tree/test_tree_clearing.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Tree
|
||||
|
||||
|
||||
class VerseBody:
|
||||
pass
|
||||
|
||||
|
||||
class VerseStar(VerseBody):
|
||||
pass
|
||||
|
||||
|
||||
class VersePlanet(VerseBody):
|
||||
pass
|
||||
|
||||
|
||||
class VerseMoon(VerseBody):
|
||||
pass
|
||||
|
||||
|
||||
class VerseTree(Tree[VerseBody]):
|
||||
pass
|
||||
|
||||
|
||||
class TreeClearApp(App[None]):
|
||||
"""Tree clearing test app."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield VerseTree("White Sun", data=VerseStar())
|
||||
|
||||
def on_mount(self) -> None:
|
||||
tree = self.query_one(VerseTree)
|
||||
node = tree.root.add("Londinium", VersePlanet())
|
||||
node.add_leaf("Balkerne", VerseMoon())
|
||||
node.add_leaf("Colchester", VerseMoon())
|
||||
node = tree.root.add("Sihnon", VersePlanet())
|
||||
node.add_leaf("Airen", VerseMoon())
|
||||
node.add_leaf("Xiaojie", VerseMoon())
|
||||
|
||||
|
||||
async def test_tree_simple_clear() -> None:
|
||||
"""Clearing a tree should keep the old root label and data."""
|
||||
async with TreeClearApp().run_test() as pilot:
|
||||
tree = pilot.app.query_one(VerseTree)
|
||||
assert len(tree.root.children) > 1
|
||||
pilot.app.query_one(VerseTree).clear()
|
||||
assert len(tree.root.children) == 0
|
||||
assert str(tree.root.label) == "White Sun"
|
||||
assert isinstance(tree.root.data, VerseStar)
|
||||
|
||||
|
||||
async def test_tree_reset_with_label() -> None:
|
||||
"""Resetting a tree with a new label should use the new label and set the data to None."""
|
||||
async with TreeClearApp().run_test() as pilot:
|
||||
tree = pilot.app.query_one(VerseTree)
|
||||
assert len(tree.root.children) > 1
|
||||
pilot.app.query_one(VerseTree).reset(label="Jiangyin")
|
||||
assert len(tree.root.children) == 0
|
||||
assert str(tree.root.label) == "Jiangyin"
|
||||
assert tree.root.data is None
|
||||
|
||||
|
||||
async def test_tree_reset_with_label_and_data() -> None:
|
||||
"""Resetting a tree with a label and data have that label and data used."""
|
||||
async with TreeClearApp().run_test() as pilot:
|
||||
tree = pilot.app.query_one(VerseTree)
|
||||
assert len(tree.root.children) > 1
|
||||
pilot.app.query_one(VerseTree).reset(label="Jiangyin", data=VersePlanet())
|
||||
assert len(tree.root.children) == 0
|
||||
assert str(tree.root.label) == "Jiangyin"
|
||||
assert isinstance(tree.root.data, VersePlanet)
|
||||
@@ -49,23 +49,26 @@ async def test_tree_node_selected_message() -> None:
|
||||
assert pilot.app.messages == ["NodeExpanded", "NodeSelected"]
|
||||
|
||||
|
||||
async def test_tree_node_selected_message_no_auto() -> None:
|
||||
"""Selecting a node should result in only a selected message being emitted."""
|
||||
async with TreeApp().run_test() as pilot:
|
||||
pilot.app.query_one(MyTree).auto_expand = False
|
||||
await pilot.press("enter")
|
||||
assert pilot.app.messages == ["NodeSelected"]
|
||||
|
||||
|
||||
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")
|
||||
assert pilot.app.messages == ["NodeExpanded", "NodeSelected"]
|
||||
await pilot.press("space")
|
||||
assert pilot.app.messages == ["NodeExpanded"]
|
||||
|
||||
|
||||
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")
|
||||
assert pilot.app.messages == [
|
||||
"NodeExpanded",
|
||||
"NodeSelected",
|
||||
"NodeCollapsed",
|
||||
"NodeSelected",
|
||||
]
|
||||
await pilot.press("space", "space")
|
||||
assert pilot.app.messages == ["NodeExpanded", "NodeCollapsed"]
|
||||
|
||||
|
||||
async def test_tree_node_highlighted_message() -> None:
|
||||
|
||||
Reference in New Issue
Block a user