Repository: Textualize/textual

Issue:
Make moving a child before or after itself, a no-op
If we try to use `move_child` to move a widget to after/before itself, should we get an error or is that a no-op?
(Add tests to tests/test_widget_child_moving.py accordingly.)

Context: this arose from writing some functionality in a TODO app that sorts TODO items according to their due date and reorders them when the due date is edited. From this point of view, it makes sense for moving to after/before itself to be a no-op.
Otherwise, the sorting code needs to take extra steps to make sure we don't try to move things to where they already are.


Discussion:
The more I think about it, the more I think I'm inclined to agree with you that it makes sense to have it be a no-op. "Move child to where it is" seems like it shouldn't error even if it's a weird thing to ask, as it's not the same as calling `move_child` and not providing anywhere, or providing somewhere out of bounds. The request is a valid location and is within bounds.
No-op makes sense.
Note to self: fix the docstrings in `move_child` as well.
Note to self x2: Dave owes me a favour.
> Note to self x2: Dave owes me a favour.

Let's call it payback for retconning my first blog post. 😛
Does this require work? What happens now if you move a child before/after itself?

I agree that should be a no-op and not an error.
Just tested this and it appears to still be a problem. I'll turn these into proper `XFAIL` tests so we've got them noted.
And the problem is what, exactly? Moving a child before itself raises an error and we think it should be a no-op?
Yes and [yes](https://github.com/Textualize/textual/issues/1743#issuecomment-1422886317).
Don't forget to [star](https://github.com/Textualize/textual) the repository!

Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.
This should also be a no-op if `before`/`after` are the indices of the widget we are trying to move.

From [this comment on Discord](https://discord.com/channels/1026214085173461072/1033754296224841768/1185063380294316133).

Resolving patch:
diff --git a/src/textual/widget.py b/src/textual/widget.py
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -892,9 +892,30 @@ def mount_all(
         await_mount = self.mount(*widgets, before=before, after=after)
         return await_mount
 
+    @overload
+    def move_child(
+        self,
+        child: int | Widget,
+        *,
+        before: int | Widget,
+        after: None = None,
+    ) -> None:
+        ...
+
+    @overload
+    def move_child(
+        self,
+        child: int | Widget,
+        *,
+        after: int | Widget,
+        before: None = None,
+    ) -> None:
+        ...
+
     def move_child(
         self,
         child: int | Widget,
+        *,
         before: int | Widget | None = None,
         after: int | Widget | None = None,
     ) -> None:
@@ -920,10 +941,6 @@ def move_child(
         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):
@@ -948,6 +965,9 @@ def _to_widget(child: int | Widget, called: str) -> Widget:
             cast("int | Widget", before if after is None else after), "move towards"
         )
 
+        if child is target:
+            return  # Nothing to be done.
+
         # At this point we should know what we're moving, and it should be a
         # child; where we're moving it to, which should be within the child
         # list; and how we're supposed to move it. All that's left is doing


Test patch:
diff --git a/tests/test_widget_child_moving.py b/tests/test_widget_child_moving.py
--- a/tests/test_widget_child_moving.py
+++ b/tests/test_widget_child_moving.py
@@ -9,7 +9,7 @@
 async def test_move_child_no_direction() -> None:
     """Test moving a widget in a child list."""
     async with App().run_test() as pilot:
-        child = Widget(Widget())
+        child = Widget()
         await pilot.app.mount(child)
         with pytest.raises(WidgetError):
             pilot.app.screen.move_child(child)
@@ -18,7 +18,7 @@ async def test_move_child_no_direction() -> None:
 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())
+        child = Widget()
         await pilot.app.mount(child)
         with pytest.raises(WidgetError):
             pilot.app.screen.move_child(child, before=1, after=2)
@@ -27,7 +27,7 @@ async def test_move_child_both_directions() -> None:
 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())
+        child = Widget()
         await pilot.app.mount(child)
         with pytest.raises(WidgetError):
             pilot.app.screen.move_child(Widget(), before=child)
@@ -36,28 +36,82 @@ async def test_move_child_not_our_child() -> None:
 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())
+        child = Widget()
         await pilot.app.mount(child)
         with pytest.raises(WidgetError):
             pilot.app.screen.move_child(child, before=Widget())
 
 
-async def test_move_child_before_itself() -> None:
-    """Test moving a widget before itself."""
+@pytest.mark.parametrize(
+    "reference",
+    [
+        "before",
+        "after",
+    ],
+)
+async def test_move_child_index_in_relation_to_itself_index(reference: str) -> None:
+    """Regression test for https://github.com/Textualize/textual/issues/1743"""
 
+    widget = Widget()
+    child = 0
+    kwargs = {reference: 0}
     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
+        await pilot.app.screen.mount(widget)
+        pilot.app.screen.move_child(child, **kwargs)  # Shouldn't raise an error.
+
+
+@pytest.mark.parametrize(
+    "reference",
+    [
+        "before",
+        "after",
+    ],
+)
+async def test_move_child_index_in_relation_to_itself_widget(reference: str) -> None:
+    """Regression test for https://github.com/Textualize/textual/issues/1743"""
+
+    widget = Widget()
+    child = 0
+    kwargs = {reference: widget}
     async with App().run_test() as pilot:
-        child = Widget(Widget())
-        await pilot.app.mount(child)
-        pilot.app.screen.move_child(child, after=child)
+        await pilot.app.screen.mount(widget)
+        pilot.app.screen.move_child(child, **kwargs)  # Shouldn't raise an error.
+
+
+@pytest.mark.parametrize(
+    "reference",
+    [
+        "before",
+        "after",
+    ],
+)
+async def test_move_child_widget_in_relation_to_itself_index(reference: str) -> None:
+    """Regression test for https://github.com/Textualize/textual/issues/1743"""
+
+    widget = Widget()
+    child = widget
+    kwargs = {reference: 0}
+    async with App().run_test() as pilot:
+        await pilot.app.screen.mount(widget)
+        pilot.app.screen.move_child(child, **kwargs)  # Shouldn't raise an error.
+
+
+@pytest.mark.parametrize(
+    "reference",
+    [
+        "before",
+        "after",
+    ],
+)
+async def test_move_child_widget_in_relation_to_itself_widget(reference: str) -> None:
+    """Regression test for https://github.com/Textualize/textual/issues/1743"""
+
+    widget = Widget()
+    child = widget
+    kwargs = {reference: widget}
+    async with App().run_test() as pilot:
+        await pilot.app.screen.mount(widget)
+        pilot.app.screen.move_child(child, **kwargs)  # Shouldn't raise an error.
 
 
 async def test_move_past_end_of_child_list() -> None: