Skip to content

Commit e79cad6

Browse files
authored
Merge pull request #1173 from davep/move-child
Add Widget.move_child
2 parents a6f4a05 + 54bf7a9 commit e79cad6

File tree

3 files changed

+164
-0
lines changed

3 files changed

+164
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2424
the return value of `DOMQuery.remove`, which uses to return `self`.
2525
https://github.com/Textualize/textual/issues/1094
2626
- Added Pilot.wait_for_animation
27+
- Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121
2728

2829
### Changed
2930

src/textual/widget.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,69 @@ def mount(
480480
self.app._register(parent, *widgets, before=before, after=after)
481481
)
482482

483+
def move_child(
484+
self,
485+
child: int | Widget,
486+
before: int | Widget | None = None,
487+
after: int | Widget | None = None,
488+
) -> None:
489+
"""Move a child widget within its parent's list of children.
490+
491+
Args:
492+
child (int | Widget): The child widget to move.
493+
before: (int | Widget, optional): Optional location to move before.
494+
after: (int | Widget, optional): Optional location to move after.
495+
496+
Raises:
497+
WidgetError: If there is a problem with the child or target.
498+
499+
Note:
500+
Only one of ``before`` or ``after`` can be provided. If neither
501+
or both are provided a ``WidgetError`` will be raised.
502+
"""
503+
504+
# One or the other of before or after are required. Can't do
505+
# neither, can't do both.
506+
if before is None and after is None:
507+
raise WidgetError("One of `before` or `after` is required.")
508+
elif before is not None and after is not None:
509+
raise WidgetError("Only one of `before`or `after` can be handled.")
510+
511+
def _to_widget(child: int | Widget, called: str) -> Widget:
512+
"""Ensure a given child reference is a Widget."""
513+
if isinstance(child, int):
514+
try:
515+
child = self.children[child]
516+
except IndexError:
517+
raise WidgetError(
518+
f"An index of {child} for the child to {called} is out of bounds"
519+
) from None
520+
else:
521+
# We got an actual widget, so let's be sure it really is one of
522+
# our children.
523+
try:
524+
_ = self.children.index(child)
525+
except ValueError:
526+
raise WidgetError(f"{child!r} is not a child of {self!r}") from None
527+
return child
528+
529+
# Ensure the child and target are widgets.
530+
child = _to_widget(child, "move")
531+
target = _to_widget(before if after is None else after, "move towards")
532+
533+
# At this point we should know what we're moving, and it should be a
534+
# child; where we're moving it to, which should be within the child
535+
# list; and how we're supposed to move it. All that's left is doing
536+
# the right thing.
537+
self.children._remove(child)
538+
if before is not None:
539+
self.children._insert(self.children.index(target), child)
540+
else:
541+
self.children._insert(self.children.index(target) + 1, child)
542+
543+
# Request a refresh.
544+
self.refresh(layout=True)
545+
483546
def compose(self) -> ComposeResult:
484547
"""Called by Textual to create child widgets.
485548

tests/test_widget_child_moving.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import pytest
2+
3+
from textual.app import App
4+
from textual.widget import Widget, WidgetError
5+
6+
async def test_widget_move_child() -> None:
7+
"""Test moving a widget in a child list."""
8+
9+
# Test calling move_child with no direction.
10+
async with App().run_test() as pilot:
11+
child = Widget(Widget())
12+
await pilot.app.mount(child)
13+
with pytest.raises(WidgetError):
14+
pilot.app.screen.move_child(child)
15+
16+
# Test calling move_child with more than one direction.
17+
async with App().run_test() as pilot:
18+
child = Widget(Widget())
19+
await pilot.app.mount(child)
20+
with pytest.raises(WidgetError):
21+
pilot.app.screen.move_child(child, before=1, after=2)
22+
23+
# Test attempting to move a child that isn't ours.
24+
async with App().run_test() as pilot:
25+
child = Widget(Widget())
26+
await pilot.app.mount(child)
27+
with pytest.raises(WidgetError):
28+
pilot.app.screen.move_child(Widget(), before=child)
29+
30+
# Test attempting to move relative to a widget that isn't a child.
31+
async with App().run_test() as pilot:
32+
child = Widget(Widget())
33+
await pilot.app.mount(child)
34+
with pytest.raises(WidgetError):
35+
pilot.app.screen.move_child(child, before=Widget())
36+
37+
# Make a background set of widgets.
38+
widgets = [Widget(id=f"widget-{n}") for n in range( 10 )]
39+
40+
# Test attempting to move past the end of the child list.
41+
async with App().run_test() as pilot:
42+
container = Widget(*widgets)
43+
await pilot.app.mount(container)
44+
with pytest.raises(WidgetError):
45+
container.move_child(widgets[0], before=len(widgets)+10)
46+
47+
# Test attempting to move before the end of the child list.
48+
async with App().run_test() as pilot:
49+
container = Widget(*widgets)
50+
await pilot.app.mount(container)
51+
with pytest.raises(WidgetError):
52+
container.move_child(widgets[0], before=-(len(widgets)+10))
53+
54+
# Test the different permutations of moving one widget before another.
55+
perms = (
56+
( 1, 0 ),
57+
( widgets[1], 0 ),
58+
( 1, widgets[ 0 ] ),
59+
( widgets[ 1 ], widgets[ 0 ])
60+
)
61+
for child, target in perms:
62+
async with App().run_test() as pilot:
63+
container = Widget(*widgets)
64+
await pilot.app.mount(container)
65+
container.move_child(child, before=target)
66+
assert container.children[0].id == "widget-1"
67+
assert container.children[1].id == "widget-0"
68+
assert container.children[2].id == "widget-2"
69+
70+
# Test the different permutations of moving one widget after another.
71+
perms = (
72+
( 0, 1 ),
73+
( widgets[0], 1 ),
74+
( 0, widgets[ 1 ] ),
75+
( widgets[ 0 ], widgets[ 1 ])
76+
)
77+
for child, target in perms:
78+
async with App().run_test() as pilot:
79+
container = Widget(*widgets)
80+
await pilot.app.mount(container)
81+
container.move_child(child, after=target)
82+
assert container.children[0].id == "widget-1"
83+
assert container.children[1].id == "widget-0"
84+
assert container.children[2].id == "widget-2"
85+
86+
# Test moving after a child after the last child.
87+
async with App().run_test() as pilot:
88+
container = Widget(*widgets)
89+
await pilot.app.mount(container)
90+
container.move_child(widgets[0], after=widgets[-1])
91+
assert container.children[0].id == "widget-1"
92+
assert container.children[-1].id == "widget-0"
93+
94+
# Test moving after a child after the last child's numeric position.
95+
async with App().run_test() as pilot:
96+
container = Widget(*widgets)
97+
await pilot.app.mount(container)
98+
container.move_child(widgets[0], after=widgets[9])
99+
assert container.children[0].id == "widget-1"
100+
assert container.children[-1].id == "widget-0"

0 commit comments

Comments
 (0)