mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into auto-focus
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -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
BIN
reference/spacing.monopic
Normal file
Binary file not shown.
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}')",
|
||||
|
||||
@@ -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
@@ -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]"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user