Repository: qtile/qtile

Issue:
Non-persistent Groups are still alive when its last window is moved to another group
### Issue description

# Issue
When a group is created with the option `persist=False` and its last window is moved to another group (instead of being kill inside that group), the non-persistent group is still alive.

## Expected behavior
A non-persistent group should be removed when it no longer contains any window and _not_ when its last window is **specifically killed inside the group**. 

## Steps to reproduce:

1. Create a group with the following configuration:
    ```python
    groups = [
        # Some property to match
        Group("a", matches=[Match(wm_class="foo")], exclusive=True),
        # other groups
    ]
    ```
2. Open a window in group `"a"` that doesn't match any of the group's matches and doesn't match any other group.
3. A new group will be created with the name of the window as the last group.
4. Move this new group to the current screen.
5. Send the (only) window to any other group.
6. The new group is still alive, even though it doesn't have any windows and it's not persistent.

The group can still be remove either through the commands API or, as a workaround, by opening a new window in that group and then killing it while the window is still in the group.

The problem seems to be that the `DGroups._del()` method at `/libqtile/dgroups.py` it's only run when the `client_kill` event is fired, so groups are only ever consider for removal when windows are killed/closed but not when windows are only removed from them, which matches the previously described behavior.

> NOTE: I've only test it X11

Is this intended behavior?

### Version

0.24.0

### Backend

X11 (default)

### Config

_No response_

### Logs

_No response_

### Required

- [X] I have searched past issues to see if this bug has already been reported, and it hasn't been.
- [X] I understand that people give their precious time for free, and thus I've done my very best to make this problem as easy as possible to investigate.


Discussion:
I'd say it is expected behaviour but only in the sense that it is doing what it should. Is it the _desired_ behaviour? Probably not given you've posted an issue.

I'll take a look when I can.

Resolving patch:
diff --git a/libqtile/backend/wayland/window.py b/libqtile/backend/wayland/window.py
--- a/libqtile/backend/wayland/window.py
+++ b/libqtile/backend/wayland/window.py
@@ -301,7 +301,17 @@ def togroup(
             if self.group.screen:
                 # for floats remove window offset
                 self.x -= self.group.screen.x
+            group_ref = self.group
             self.group.remove(self)
+            # delete groups with `persist=False`
+            if (
+                not self.qtile.dgroups.groups_map[group_ref.name].persist
+                and len(group_ref.windows) <= 1
+            ):
+                # set back original group so _del() can grab it
+                self.group = group_ref
+                self.qtile.dgroups._del(self)
+                self.group = None
 
         if group.screen and self.x < group.screen.x:
             self.x += group.screen.x
diff --git a/libqtile/backend/x11/window.py b/libqtile/backend/x11/window.py
--- a/libqtile/backend/x11/window.py
+++ b/libqtile/backend/x11/window.py
@@ -2009,7 +2009,16 @@ def togroup(self, group_name=None, *, switch_group=False, toggle=False):
             if self.group.screen:
                 # for floats remove window offset
                 self.x -= self.group.screen.x
+            group_ref = self.group
             self.group.remove(self)
+            if (
+                not self.qtile.dgroups.groups_map[group_ref.name].persist
+                and len(group_ref.windows) <= 1
+            ):
+                # set back original group so _del() can grab it
+                self.group = group_ref
+                self.qtile.dgroups._del(self)
+                self.group = None
 
         if group.screen and self.x < group.screen.x:
             self.x += group.screen.x
diff --git a/libqtile/dgroups.py b/libqtile/dgroups.py
--- a/libqtile/dgroups.py
+++ b/libqtile/dgroups.py
@@ -254,4 +254,5 @@ def delete_client():
             del self.timeout[client]
 
         logger.debug("Deleting %s in %ss", group, self.delay)
-        self.timeout[client] = self.qtile.call_later(self.delay, delete_client)
+        if client not in self.timeout:
+            self.timeout[client] = self.qtile.call_later(self.delay, delete_client)


Test patch:
diff --git a/test/test_group.py b/test/test_group.py
--- a/test/test_group.py
+++ b/test/test_group.py
@@ -28,6 +28,7 @@
 import libqtile.config
 from libqtile import layout
 from libqtile.confreader import Config
+from test.helpers import Retry
 
 
 class GroupConfig(Config):
@@ -102,3 +103,36 @@ def test_toscreen_toggle(manager):
     manager.c.group["b"].toscreen(toggle=True)
     manager.c.group["b"].toscreen(toggle=True)
     assert manager.c.group.info()["name"] == "a"  # Toggling twice roundtrips between the two
+
+
+class NoPersistGroupConfig(GroupConfig):
+    groups = [
+        libqtile.config.Group("a"),
+        libqtile.config.Group("b", exclusive=True),
+        libqtile.config.Group("c", persist=False),
+    ]
+
+
+@pytest.mark.parametrize("manager", [NoPersistGroupConfig], indirect=True)
+def test_non_persistent_groups(manager):
+    @Retry(ignore_exceptions=(AssertionError,))
+    def wait_for_removed(group_name):
+        assert group_name not in manager.c.get_groups()
+
+    window_name = "no_match"
+    manager.c.group["b"].toscreen()
+    manager.test_window(window_name)
+    assert window_name not in manager.c.group.info()["windows"]  # Window was moved to a new group
+    group_name = "TestWindow"  # The new group is named after the window's `wm_class` property
+    assert group_name in manager.c.get_groups()
+    manager.c.group[group_name].toscreen()
+    assert manager.c.window.info()["name"] == window_name
+    manager.c.window.togroup("a")
+    wait_for_removed(group_name)
+
+    window_name = "bar"
+    manager.c.group["c"].toscreen()
+    manager.test_window(window_name)
+    assert manager.c.window.info()["name"] == window_name
+    manager.c.window.togroup("a")
+    wait_for_removed(group_name)