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

View File

@@ -316,6 +316,7 @@ class App(Generic[ReturnType], DOMNode):
the name of the app if it doesn't. the name of the app if it doesn't.
Assign a new value to this attribute to change the title. 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 "" 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. the file being worker on.
Assign a new value to this attribute to change the sub-title. 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) self._logger = Logger(self._log)
@@ -406,6 +408,14 @@ class App(Generic[ReturnType], DOMNode):
self.set_class(self.dark, "-dark-mode") self.set_class(self.dark, "-dark-mode")
self.set_class(not self.dark, "-light-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 @property
def workers(self) -> WorkerManager: def workers(self) -> WorkerManager:
"""The [worker](guide/workers/) manager. """The [worker](guide/workers/) manager.

View File

@@ -887,7 +887,7 @@ class DOMNode(MessagePump):
Example: 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 ```python
def on_dark_change(old_value:bool, new_value:bool): def on_dark_change(old_value:bool, new_value:bool):

View File

@@ -839,7 +839,7 @@ class Region(NamedTuple):
@lru_cache(maxsize=1024) @lru_cache(maxsize=1024)
def split_horizontal(self, cut: int) -> tuple[Region, Region]: 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: Args:
cut: An offset from self.x where the cut should be made. May be negative, cut: An offset from self.y where the cut should be made. May be negative,
for the offset to start from the right edge. for the offset to start from the lower edge.
Returns: Returns:
Two regions, which add up to the original (self). Two regions, which add up to the original (self).
@@ -909,7 +909,19 @@ class Region(NamedTuple):
class Spacing(NamedTuple): class Spacing(NamedTuple):
"""The spacing around a renderable, such as padding and border """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: Example:
```python ```python

View File

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

View File

@@ -795,19 +795,15 @@ class Widget(DOMNode):
Args: Args:
child: The child widget to move. child: The child widget to move.
before: Optional location to move before. An `int` is the index before: Child widget or location index to move before.
of the child to move before, a `str` is a `query_one` query to after: Child widget or location index to move after.
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.
Raises: Raises:
WidgetError: If there is a problem with the child or target. WidgetError: If there is a problem with the child or target.
Note: Note:
Only one of ``before`` or ``after`` can be provided. If neither Only one of `before` or `after` can be provided. If neither
or both are provided a ``WidgetError`` will be raised. or both are provided a `WidgetError` will be raised.
""" """
# One or the other of before or after are required. Can't do # 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: elif before is not None and after is not None:
raise WidgetError("Only one of `before` or `after` can be handled.") 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: def _to_widget(child: int | Widget, called: str) -> Widget:
"""Ensure a given child reference is a Widget.""" """Ensure a given child reference is a Widget."""
if isinstance(child, int): if isinstance(child, int):

View File

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

View File

@@ -207,6 +207,7 @@ class TreeNode(Generic[TreeDataType]):
""" """
self._expanded = True self._expanded = True
self._updates += 1 self._updates += 1
self._tree.post_message(Tree.NodeExpanded(self._tree, self))
if expand_all: if expand_all:
for child in self.children: for child in self.children:
child._expand(expand_all) child._expand(expand_all)
@@ -239,6 +240,7 @@ class TreeNode(Generic[TreeDataType]):
""" """
self._expanded = False self._expanded = False
self._updates += 1 self._updates += 1
self._tree.post_message(Tree.NodeCollapsed(self._tree, self))
if collapse_all: if collapse_all:
for child in self.children: for child in self.children:
child._collapse(collapse_all) child._collapse(collapse_all)
@@ -1157,10 +1159,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
return return
if node.is_expanded: if node.is_expanded:
node.collapse() node.collapse()
self.post_message(self.NodeCollapsed(self, node))
else: else:
node.expand() node.expand()
self.post_message(self.NodeExpanded(self, node))
async def _on_click(self, event: events.Click) -> None: async def _on_click(self, event: events.Click) -> None:
meta = event.style.meta 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 # We've hovered, so ensure the pseudoclass is present and background changed
assert button.pseudo_classes == {"enabled", "hover"} assert button.pseudo_classes == {"enabled", "hover"}
assert button.styles.background != initial_background 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 fractions import Fraction
from itertools import chain
import pytest import pytest
@@ -122,3 +123,22 @@ def test_resolve_fraction_unit():
Fraction(32), Fraction(32),
resolve_dimension="width", resolve_dimension="width",
) == Fraction(2) ) == 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 import pytest
from textual.app import App from textual.app import App
from textual.widget import Widget, WidgetError 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 moving a widget in a child list."""
# Test calling move_child with no direction.
async with App().run_test() as pilot: async with App().run_test() as pilot:
child = Widget(Widget()) child = Widget(Widget())
await pilot.app.mount(child) await pilot.app.mount(child)
with pytest.raises(WidgetError): with pytest.raises(WidgetError):
pilot.app.screen.move_child(child) 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: async with App().run_test() as pilot:
child = Widget(Widget()) child = Widget(Widget())
await pilot.app.mount(child) await pilot.app.mount(child)
with pytest.raises(WidgetError): with pytest.raises(WidgetError):
pilot.app.screen.move_child(child, before=1, after=2) 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: async with App().run_test() as pilot:
child = Widget(Widget()) child = Widget(Widget())
await pilot.app.mount(child) await pilot.app.mount(child)
with pytest.raises(WidgetError): with pytest.raises(WidgetError):
pilot.app.screen.move_child(Widget(), before=child) 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: async with App().run_test() as pilot:
child = Widget(Widget()) child = Widget(Widget())
await pilot.app.mount(child) await pilot.app.mount(child)
with pytest.raises(WidgetError): with pytest.raises(WidgetError):
pilot.app.screen.move_child(child, before=Widget()) 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: 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) container = Widget(*widgets)
await pilot.app.mount(container) await pilot.app.mount(container)
with pytest.raises(WidgetError): with pytest.raises(WidgetError):
container.move_child(widgets[0], before=len(widgets) + 10) 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: async with App().run_test() as pilot:
widgets = [Widget(id=f"widget-{n}") for n in range(10)]
container = Widget(*widgets) container = Widget(*widgets)
await pilot.app.mount(container) await pilot.app.mount(container)
with pytest.raises(WidgetError): with pytest.raises(WidgetError):
container.move_child(widgets[0], before=-(len(widgets) + 10)) 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])) perms = ((1, 0), (widgets[1], 0), (1, widgets[0]), (widgets[1], widgets[0]))
for child, target in perms: for child, target in perms:
async with App().run_test() as pilot: 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[1].id == "widget-0"
assert container._nodes[2].id == "widget-2" 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])) perms = ((0, 1), (widgets[0], 1), (0, widgets[1]), (widgets[0], widgets[1]))
for child, target in perms: for child, target in perms:
async with App().run_test() as pilot: 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[1].id == "widget-0"
assert container._nodes[2].id == "widget-2" 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: async with App().run_test() as pilot:
widgets = [Widget(id=f"widget-{n}") for n in range(10)]
container = Widget(*widgets) container = Widget(*widgets)
await pilot.app.mount(container) await pilot.app.mount(container)
container.move_child(widgets[0], after=widgets[-1]) container.move_child(widgets[0], after=widgets[-1])
assert container._nodes[0].id == "widget-1" assert container._nodes[0].id == "widget-1"
assert container._nodes[-1].id == "widget-0" 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: async with App().run_test() as pilot:
widgets = [Widget(id=f"widget-{n}") for n in range(10)]
container = Widget(*widgets) container = Widget(*widgets)
await pilot.app.mount(container) await pilot.app.mount(container)
container.move_child(widgets[0], after=widgets[9]) 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")] 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: async def test_tree_node_collapsed_message() -> None:
"""Collapsing a node should result in a collapsed message being emitted.""" """Collapsing a node should result in a collapsed message being emitted."""
async with TreeApp().run_test() as pilot: 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: async def test_tree_node_highlighted_message() -> None:
"""Highlighting a node should result in a highlighted message being emitted.""" """Highlighting a node should result in a highlighted message being emitted."""
async with TreeApp().run_test() as pilot: async with TreeApp().run_test() as pilot: