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

This commit is contained in:
Darren Burns
2023-02-06 12:46:07 +00:00
86 changed files with 2564 additions and 676 deletions

View 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)
]

View File

@@ -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:

View File

@@ -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

View 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()

View 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()

View File

@@ -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()

View File

@@ -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"]
)

View File

@@ -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
View 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"

View File

@@ -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")]

View File

@@ -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 = [
"┏━━━━━┓",
"┃ ┃",

View 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)

View File

@@ -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: