Merge branch 'main' into auto-focus

This commit is contained in:
Rodrigo Girão Serrão
2023-05-15 10:41:15 +01:00
committed by GitHub
15 changed files with 2919 additions and 2722 deletions

View File

@@ -6,11 +6,24 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased
## Unrealeased
### Changed
- App `title` and `sub_title` attributes can be set to any type https://github.com/Textualize/textual/issues/2521
- Using `Widget.move_child` where the target and the child being moved are the same is now a no-op https://github.com/Textualize/textual/issues/1743
### Fixed
- Fixed `ZeroDivisionError` in `resolve_fraction_unit` https://github.com/Textualize/textual/issues/2502
- Fixed `TreeNode.expand` and `TreeNode.expand_all` not posting a `Tree.NodeExpanded` message https://github.com/Textualize/textual/issues/2535
- Fixed `TreeNode.collapse` and `TreeNode.collapse_all` not posting a `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535
- Fixed `TreeNode.toggle` and `TreeNode.toggle_all` not posting a `Tree.NodeExpanded` or `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535
- `footer--description` component class was being ignored https://github.com/Textualize/textual/issues/2544
### Added
- Attribute `auto_focus` to screens https://github.com/Textualize/textual/issues/2457
- Class variable `AUTO_FOCUS` to screens https://github.com/Textualize/textual/issues/2457
## [0.24.1] - 2023-05-08

BIN
reference/spacing.monopic Normal file

Binary file not shown.

View File

@@ -143,7 +143,7 @@ def resolve_fraction_unit(
resolved: list[Fraction | None] = [None] * len(resolve)
remaining_fraction = Fraction(sum(scalar.value for scalar, _, _ in resolve))
while True:
while remaining_fraction > 0:
remaining_space_changed = False
resolve_fraction = Fraction(remaining_space, remaining_fraction)
for index, (scalar, min_value, max_value) in enumerate(resolve):
@@ -166,7 +166,7 @@ def resolve_fraction_unit(
return (
Fraction(remaining_space, remaining_fraction)
if remaining_space
if remaining_space > 0
else Fraction(1)
)

View File

@@ -316,6 +316,7 @@ class App(Generic[ReturnType], DOMNode):
the name of the app if it doesn't.
Assign a new value to this attribute to change the title.
The new value is always converted to string.
"""
self.sub_title = self.SUB_TITLE if self.SUB_TITLE is not None else ""
@@ -328,6 +329,7 @@ class App(Generic[ReturnType], DOMNode):
the file being worker on.
Assign a new value to this attribute to change the sub-title.
The new value is always converted to string.
"""
self._logger = Logger(self._log)
@@ -406,6 +408,14 @@ class App(Generic[ReturnType], DOMNode):
self.set_class(self.dark, "-dark-mode")
self.set_class(not self.dark, "-light-mode")
def validate_title(self, title: Any) -> str:
"""Make sure the title is set to a string."""
return str(title)
def validate_sub_title(self, sub_title: Any) -> str:
"""Make sure the sub-title is set to a string."""
return str(sub_title)
@property
def workers(self) -> WorkerManager:
"""The [worker](guide/workers/) manager.

View File

@@ -887,7 +887,7 @@ class DOMNode(MessagePump):
Example:
Here's how you could detect when the app changes from dark to light mode (and visa versa).
Here's how you could detect when the app changes from dark to light mode (and vice versa).
```python
def on_dark_change(old_value:bool, new_value:bool):

View File

@@ -839,7 +839,7 @@ class Region(NamedTuple):
@lru_cache(maxsize=1024)
def split_horizontal(self, cut: int) -> tuple[Region, Region]:
"""Split a region in to two, from a given x offset.
"""Split a region in to two, from a given y offset.
```
┌─────────┐
@@ -852,8 +852,8 @@ class Region(NamedTuple):
```
Args:
cut: An offset from self.x where the cut should be made. May be negative,
for the offset to start from the right edge.
cut: An offset from self.y where the cut should be made. May be negative,
for the offset to start from the lower edge.
Returns:
Two regions, which add up to the original (self).
@@ -909,7 +909,19 @@ class Region(NamedTuple):
class Spacing(NamedTuple):
"""The spacing around a renderable, such as padding and border
Spacing is defined by four integers for the space at the top, right, bottom, and left of a region,
Spacing is defined by four integers for the space at the top, right, bottom, and left of a region.
```
┌ ─ ─ ─ ─ ─ ─ ─▲─ ─ ─ ─ ─ ─ ─ ─ ┐
│ top
│ ┏━━━━━▼━━━━━━┓ │
◀──────▶┃ ┃◀───────▶
│ left ┃ ┃ right │
┃ ┃
│ ┗━━━━━▲━━━━━━┛ │
│ bottom
└ ─ ─ ─ ─ ─ ─ ─▼─ ─ ─ ─ ─ ─ ─ ─ ┘
```
Example:
```python

View File

@@ -379,16 +379,22 @@ class Strip:
"""
pos = 0
cell_length = self.cell_length
cuts = [cut for cut in cuts if cut <= cell_length]
cache_key = tuple(cuts)
cached = self._divide_cache.get(cache_key)
if cached is not None:
return cached
strips: list[Strip] = []
add_strip = strips.append
for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
add_strip(Strip(segments, cut - pos))
pos = cut
strips: list[Strip]
if cuts == [cell_length]:
strips = [self]
else:
strips = []
add_strip = strips.append
for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
add_strip(Strip(segments, cut - pos))
pos = cut
self._divide_cache[cache_key] = strips
return strips

View File

@@ -795,19 +795,15 @@ class Widget(DOMNode):
Args:
child: The child widget to move.
before: Optional location to move before. An `int` is the index
of the child to move before, a `str` is a `query_one` query to
find the widget to move before.
after: Optional location to move after. An `int` is the index
of the child to move after, a `str` is a `query_one` query to
find the widget to move after.
before: Child widget or location index to move before.
after: Child widget or location index to move after.
Raises:
WidgetError: If there is a problem with the child or target.
Note:
Only one of ``before`` or ``after`` can be provided. If neither
or both are provided a ``WidgetError`` will be raised.
Only one of `before` or `after` can be provided. If neither
or both are provided a `WidgetError` will be raised.
"""
# One or the other of before or after are required. Can't do
@@ -817,6 +813,10 @@ class Widget(DOMNode):
elif before is not None and after is not None:
raise WidgetError("Only one of `before` or `after` can be handled.")
# We short-circuit the no-op, otherwise it will error later down the road.
if child is before or child is after:
return
def _to_widget(child: int | Widget, called: str) -> Widget:
"""Ensure a given child reference is a Widget."""
if isinstance(child, int):

View File

@@ -98,6 +98,7 @@ class Footer(Widget):
highlight_style = self.get_component_rich_style("footer--highlight")
highlight_key_style = self.get_component_rich_style("footer--highlight-key")
key_style = self.get_component_rich_style("footer--key")
description_style = self.get_component_rich_style("footer--description")
bindings = [
binding
@@ -122,7 +123,7 @@ class Footer(Widget):
(f" {key_display} ", highlight_key_style if hovered else key_style),
(
f" {binding.description} ",
highlight_style if hovered else base_style,
highlight_style if hovered else base_style + description_style,
),
meta={
"@click": f"app.check_bindings('{binding.key}')",

View File

@@ -207,6 +207,7 @@ class TreeNode(Generic[TreeDataType]):
"""
self._expanded = True
self._updates += 1
self._tree.post_message(Tree.NodeExpanded(self._tree, self))
if expand_all:
for child in self.children:
child._expand(expand_all)
@@ -239,6 +240,7 @@ class TreeNode(Generic[TreeDataType]):
"""
self._expanded = False
self._updates += 1
self._tree.post_message(Tree.NodeCollapsed(self._tree, self))
if collapse_all:
for child in self.children:
child._collapse(collapse_all)
@@ -1157,10 +1159,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
return
if node.is_expanded:
node.collapse()
self.post_message(self.NodeCollapsed(self, node))
else:
node.expand()
self.post_message(self.NodeExpanded(self, node))
async def _on_click(self, event: events.Click) -> None:
meta = event.style.meta

File diff suppressed because one or more lines are too long

View File

@@ -36,3 +36,33 @@ async def test_hover_update_styles():
# We've hovered, so ensure the pseudoclass is present and background changed
assert button.pseudo_classes == {"enabled", "hover"}
assert button.styles.background != initial_background
def test_setting_title():
app = MyApp()
app.title = None
assert app.title == "None"
app.title = ""
assert app.title == ""
app.title = 0.125
assert app.title == "0.125"
app.title = [True, False, 2]
assert app.title == "[True, False, 2]"
def test_setting_sub_title():
app = MyApp()
app.sub_title = None
assert app.sub_title == "None"
app.sub_title = ""
assert app.sub_title == ""
app.sub_title = 0.125
assert app.sub_title == "0.125"
app.sub_title = [True, False, 2]
assert app.sub_title == "[True, False, 2]"

View File

@@ -1,4 +1,5 @@
from fractions import Fraction
from itertools import chain
import pytest
@@ -122,3 +123,22 @@ def test_resolve_fraction_unit():
Fraction(32),
resolve_dimension="width",
) == Fraction(2)
def test_resolve_issue_2502():
"""Test https://github.com/Textualize/textual/issues/2502"""
widget = Widget()
widget.styles.width = "1fr"
widget.styles.min_width = 11
assert isinstance(
resolve_fraction_unit(
(widget.styles,),
Size(80, 24),
Size(80, 24),
Fraction(10),
resolve_dimension="width",
),
Fraction,
)

View File

@@ -1,58 +1,88 @@
from __future__ import annotations
import pytest
from textual.app import App
from textual.widget import Widget, WidgetError
async def test_widget_move_child() -> None:
async def test_move_child_no_direction() -> None:
"""Test moving a widget in a child list."""
# Test calling move_child with no direction.
async with App().run_test() as pilot:
child = Widget(Widget())
await pilot.app.mount(child)
with pytest.raises(WidgetError):
pilot.app.screen.move_child(child)
# Test calling move_child with more than one direction.
async def test_move_child_both_directions() -> None:
"""Test calling move_child with more than one direction."""
async with App().run_test() as pilot:
child = Widget(Widget())
await pilot.app.mount(child)
with pytest.raises(WidgetError):
pilot.app.screen.move_child(child, before=1, after=2)
# Test attempting to move a child that isn't ours.
async def test_move_child_not_our_child() -> None:
"""Test attempting to move a child that isn't ours."""
async with App().run_test() as pilot:
child = Widget(Widget())
await pilot.app.mount(child)
with pytest.raises(WidgetError):
pilot.app.screen.move_child(Widget(), before=child)
# Test attempting to move relative to a widget that isn't a child.
async def test_move_child_to_outside() -> None:
"""Test attempting to move relative to a widget that isn't a child."""
async with App().run_test() as pilot:
child = Widget(Widget())
await pilot.app.mount(child)
with pytest.raises(WidgetError):
pilot.app.screen.move_child(child, before=Widget())
# Make a background set of widgets.
widgets = [Widget(id=f"widget-{n}") for n in range(10)]
# Test attempting to move past the end of the child list.
async def test_move_child_before_itself() -> None:
"""Test moving a widget before itself."""
async with App().run_test() as pilot:
child = Widget(Widget())
await pilot.app.mount(child)
pilot.app.screen.move_child(child, before=child)
async def test_move_child_after_itself() -> None:
"""Test moving a widget after itself."""
# Regression test for https://github.com/Textualize/textual/issues/1743
async with App().run_test() as pilot:
child = Widget(Widget())
await pilot.app.mount(child)
pilot.app.screen.move_child(child, after=child)
async def test_move_past_end_of_child_list() -> None:
"""Test attempting to move past the end of the child list."""
async with App().run_test() as pilot:
widgets = [Widget(id=f"widget-{n}") for n in range(10)]
container = Widget(*widgets)
await pilot.app.mount(container)
with pytest.raises(WidgetError):
container.move_child(widgets[0], before=len(widgets) + 10)
# Test attempting to move before the end of the child list.
async def test_move_before_end_of_child_list() -> None:
"""Test attempting to move before the end of the child list."""
async with App().run_test() as pilot:
widgets = [Widget(id=f"widget-{n}") for n in range(10)]
container = Widget(*widgets)
await pilot.app.mount(container)
with pytest.raises(WidgetError):
container.move_child(widgets[0], before=-(len(widgets) + 10))
# Test the different permutations of moving one widget before another.
async def test_move_before_permutations() -> None:
"""Test the different permutations of moving one widget before another."""
widgets = [Widget(id=f"widget-{n}") for n in range(10)]
perms = ((1, 0), (widgets[1], 0), (1, widgets[0]), (widgets[1], widgets[0]))
for child, target in perms:
async with App().run_test() as pilot:
@@ -63,7 +93,10 @@ async def test_widget_move_child() -> None:
assert container._nodes[1].id == "widget-0"
assert container._nodes[2].id == "widget-2"
# Test the different permutations of moving one widget after another.
async def test_move_after_permutations() -> None:
"""Test the different permutations of moving one widget after another."""
widgets = [Widget(id=f"widget-{n}") for n in range(10)]
perms = ((0, 1), (widgets[0], 1), (0, widgets[1]), (widgets[0], widgets[1]))
for child, target in perms:
async with App().run_test() as pilot:
@@ -74,16 +107,22 @@ async def test_widget_move_child() -> None:
assert container._nodes[1].id == "widget-0"
assert container._nodes[2].id == "widget-2"
# Test moving after a child after the last child.
async def test_move_child_after_last_child() -> None:
"""Test moving after a child after the last child."""
async with App().run_test() as pilot:
widgets = [Widget(id=f"widget-{n}") for n in range(10)]
container = Widget(*widgets)
await pilot.app.mount(container)
container.move_child(widgets[0], after=widgets[-1])
assert container._nodes[0].id == "widget-1"
assert container._nodes[-1].id == "widget-0"
# Test moving after a child after the last child's numeric position.
async def test_move_child_after_last_numeric_location() -> None:
"""Test moving after a child after the last child's numeric position."""
async with App().run_test() as pilot:
widgets = [Widget(id=f"widget-{n}") for n in range(10)]
container = Widget(*widgets)
await pilot.app.mount(container)
container.move_child(widgets[0], after=widgets[9])

View File

@@ -78,6 +78,20 @@ async def test_tree_node_expanded_message() -> None:
assert pilot.app.messages == [("NodeExpanded", "test-tree")]
async def tree_node_expanded_by_code_message() -> None:
"""Expanding a node via the API should result in an expanded message being posted."""
async with TreeApp().run_test() as pilot:
pilot.app.query_one(Tree).root.children[0].expand()
assert pilot.app.messages == [("NodeExpanded", "test-tree")]
async def tree_node_all_expanded_by_code_message() -> None:
"""Expanding all nodes via the API should result in expanded messages being posted."""
async with TreeApp().run_test() as pilot:
pilot.app.query_one(Tree).root.children[0].expand_all()
assert pilot.app.messages == [("NodeExpanded", "test-tree")]
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:
@@ -89,6 +103,46 @@ async def test_tree_node_collapsed_message() -> None:
]
async def tree_node_collapsed_by_code_message() -> None:
"""Collapsing a node via the API should result in a collapsed message being posted."""
async with TreeApp().run_test() as pilot:
pilot.app.query_one(Tree).root.children[0].expand().collapse()
assert pilot.app.messages == [
("NodeExpanded", "test-tree"),
("NodeCollapsed", "test-tree"),
]
async def tree_node_all_collapsed_by_code_message() -> None:
"""Collapsing all nodes via the API should result in collapsed messages being posted."""
async with TreeApp().run_test() as pilot:
pilot.app.query_one(Tree).root.children[0].expand_all().collapse_all()
assert pilot.app.messages == [
("NodeExpanded", "test-tree"),
("NodeCollapsed", "test-tree"),
]
async def tree_node_toggled_by_code_message() -> None:
"""Toggling a node twice via the API should result in expanded and collapsed messages."""
async with TreeApp().run_test() as pilot:
pilot.app.query_one(Tree).root.children[0].toggle().toggle()
assert pilot.app.messages == [
("NodeExpanded", "test-tree"),
("NodeCollapsed", "test-tree"),
]
async def tree_node_all_toggled_by_code_message() -> None:
"""Toggling all nodes twice via the API should result in expanded and collapsed messages."""
async with TreeApp().run_test() as pilot:
pilot.app.query_one(Tree).root.children[0].toggle_all().toggle_all()
assert pilot.app.messages == [
("NodeExpanded", "test-tree"),
("NodeCollapsed", "test-tree"),
]
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: