Repository: qtile/qtile

Issue:
[Feature Request] Window.togroup with toggle
The `toscreen` method of `Group` has a `toggle` flag that allows toggling to the previous group if the target group is already shown on screen.

It would be nice to have this flag available on the `togroup` method of `Window` as well (and behaving correctly with the `switch_group` flag).

Use case: I use `mod4 + number` to navigate in groups, with toggling activated. I use `shift + mod4 + number` to move the active window in groups, with group switching activated. I would like window movement to behave consistently with group navigation w.r.t. toggling.


Discussion:
I'm a bit confused as to what you're asking.

Let's say you're on group 2 and group 1 was your previous group. If you press `shift + mod4 + 2`, what do you want to happen? Do you want the window to go to group 1? Do you want the window to stay on group 2 but you change to group 1 (like the `toggle` option)?
Assuming that I'm on group 2 with previous group 1 and I call the method `Window.togroup` with group 2, here is what I expect to happen:

| | `toggle=False` | `toggle=True` |
| --- | --- | --- |
| `switch_group=False` | nothing | window moves to group 1, group 2 remains shown |
| `switch_group=True` | nothing | window moves to group 1, screen shows group 1 |

The cases `toggle=False` correspond to the current behavior, which is that nothing happens if the target group is the current one.

In terms of screen switching (disregarding window movement), the `switch_group=True` cases are consistent with the `toggle` option on `Group.toscreen`.

The `toggle` flag decides which group is the *actual* target (both for window movement and potentially screen switching); as to whether the screen *does* switch, this remains controlled by `switch_group`.
I don't know why this took me so long to understand :laughing: 

So it's: when trying to moving the current window to the current group (the group it's already on), instead send it to the screen's previous group? If so, something like this might work (untested):
```py
from libqtile import qtile

def togroup_toggle(window, group):
    if window.group and window.group.name == group:  # Already on group
        if qtile.current_screen.previous_group:
            window.cmd_togroup(qtile.current_screen.previous_group.name)
    else:
        window.cmd_togroup(group)
```
You would then bind this to a key with `lazy.window.function(togroup_toggle, "2")`, where the `"2"` here is the group name, the second argument to the function. Is that the logic you're after? You may need to insert the `switch_group` value you want.
Yes that's the behavior that I would like.

Resolving patch:
diff --git a/libqtile/backend/base.py b/libqtile/backend/base.py
--- a/libqtile/backend/base.py
+++ b/libqtile/backend/base.py
@@ -319,10 +319,15 @@ def focus(self, warp: bool) -> None:
         """Focus this window and optional warp the pointer to it."""
 
     @abstractmethod
-    def togroup(self, group_name: str | None = None, *, switch_group: bool = False) -> None:
+    def togroup(
+        self, group_name: str | None = None, *, switch_group: bool = False, toggle: bool = False
+    ) -> None:
         """Move window to a specified group
 
         Also switch to that group if switch_group is True.
+
+        If `toggle` is True and and the specified group is already on the screen,
+        use the last used group as target instead.
         """
 
     @property
@@ -429,18 +434,22 @@ def cmd_togroup(
         group_name: str | None = None,
         groupName: str | None = None,  # Deprecated  # noqa: N803
         switch_group: bool = False,
+        toggle: bool = False,
     ) -> None:
         """Move window to a specified group
 
         Also switch to that group if `switch_group` is True.
 
+        If `toggle` is True and and the specified group is already on the screen,
+        use the last used group as target instead.
+
         `groupName` is deprecated and will be dropped soon. Please use `group_name`
         instead.
         """
         if groupName is not None:
             logger.warning("Window.cmd_togroup's groupName is deprecated; use group_name")
             group_name = groupName
-        self.togroup(group_name, switch_group=switch_group)
+        self.togroup(group_name, switch_group=switch_group, toggle=toggle)
 
     def cmd_toscreen(self, index: int | None = None) -> None:
         """Move window to a specified screen.
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
@@ -247,11 +247,16 @@ def focus(self, warp: bool) -> None:
 
         hook.fire("client_focus", self)
 
-    def togroup(self, group_name: str | None = None, *, switch_group: bool = False) -> None:
+    def togroup(
+        self, group_name: str | None = None, *, switch_group: bool = False, toggle: bool = False
+    ) -> None:
         """
         Move window to a specified group
 
         Also switch to that group if switch_group is True.
+
+        If `toggle` is True and and the specified group is already on the screen,
+        use the last used group as target instead.
         """
         if group_name is None:
             group = self.qtile.current_group
@@ -260,19 +265,24 @@ def togroup(self, group_name: str | None = None, *, switch_group: bool = False)
                 raise CommandError("No such group: %s" % group_name)
             group = self.qtile.groups_map[group_name]
 
-        if self.group is not group:
-            self.hide()
-            if self.group:
-                if self.group.screen:
-                    # for floats remove window offset
-                    self.x -= self.group.screen.x
-                self.group.remove(self)
-
-            if group.screen and self.x < group.screen.x:
-                self.x += group.screen.x
-            group.add(self)
-            if switch_group:
-                group.cmd_toscreen(toggle=False)
+        if self.group is group:
+            if toggle and hasattr(self.group.screen, "previous_group"):
+                group = self.group.screen.previous_group
+            else:
+                return
+
+        self.hide()
+        if self.group:
+            if self.group.screen:
+                # for floats remove window offset
+                self.x -= self.group.screen.x
+            self.group.remove(self)
+
+        if group.screen and self.x < group.screen.x:
+            self.x += group.screen.x
+        group.add(self)
+        if switch_group:
+            group.cmd_toscreen(toggle=toggle)
 
     def paint_borders(self, color: ColorsType | None, width: int) -> None:
         if color:
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
@@ -1533,10 +1533,13 @@ def set_group(self):
             if group != self.qtile.current_screen.group:
                 self.hide()
 
-    def togroup(self, group_name=None, *, switch_group=False):
+    def togroup(self, group_name=None, *, switch_group=False, toggle=False):
         """Move window to a specified group
 
         Also switch to that group if switch_group is True.
+
+        If `toggle` is True and and the specified group is already on the screen,
+        use the last used group as target instead.
         """
         if group_name is None:
             group = self.qtile.current_group
@@ -1545,19 +1548,24 @@ def togroup(self, group_name=None, *, switch_group=False):
             if group is None:
                 raise CommandError("No such group: %s" % group_name)
 
-        if self.group is not group:
-            self.hide()
-            if self.group:
-                if self.group.screen:
-                    # for floats remove window offset
-                    self.x -= self.group.screen.x
-                self.group.remove(self)
+        if self.group is group:
+            if toggle and hasattr(self.group.screen, "previous_group"):
+                group = self.group.screen.previous_group
+            else:
+                return
 
-            if group.screen and self.x < group.screen.x:
-                self.x += group.screen.x
-            group.add(self)
-            if switch_group:
-                group.cmd_toscreen(toggle=False)
+        self.hide()
+        if self.group:
+            if self.group.screen:
+                # for floats remove window offset
+                self.x -= self.group.screen.x
+            self.group.remove(self)
+
+        if group.screen and self.x < group.screen.x:
+            self.x += group.screen.x
+        group.add(self)
+        if switch_group:
+            group.cmd_toscreen(toggle=toggle)
 
     def match(self, match):
         """Match window against given attributes.


Test patch:
diff --git a/test/test_window.py b/test/test_window.py
--- a/test/test_window.py
+++ b/test/test_window.py
@@ -66,6 +66,39 @@ def test_no_size_hint(manager):
     assert manager.c.window.info()["height"] == 200
 
 
+@bare_config
+def test_togroup_toggle(manager):
+    manager.test_window("one")
+    assert manager.c.group.info()["name"] == "a"  # Start on "a"
+    assert manager.c.groups()["a"]["focus"] == "one"
+    assert manager.c.groups()["b"]["focus"] is None
+    manager.c.window.togroup("b", switch_group=True)
+    assert manager.c.group.info()["name"] == "b"  # Move the window and switch to "b"
+    assert manager.c.groups()["a"]["focus"] is None
+    assert manager.c.groups()["b"]["focus"] == "one"
+    manager.c.window.togroup("b", switch_group=True)
+    assert manager.c.group.info()["name"] == "b"  # Does not toggle by default
+    assert manager.c.groups()["a"]["focus"] is None
+    assert manager.c.groups()["b"]["focus"] == "one"
+    manager.c.window.togroup("b", switch_group=True, toggle=True)
+    assert (
+        manager.c.group.info()["name"] == "a"
+    )  # Explicitly toggling moves the window and switches to "a"
+    assert manager.c.groups()["a"]["focus"] == "one"
+    assert manager.c.groups()["b"]["focus"] is None
+    manager.c.window.togroup("b", switch_group=True, toggle=True)
+    manager.c.window.togroup("b", switch_group=True, toggle=True)
+    assert manager.c.group.info()["name"] == "a"  # Toggling twice roundtrips between the two
+    assert manager.c.groups()["a"]["focus"] == "one"
+    assert manager.c.groups()["b"]["focus"] is None
+    manager.c.window.togroup("b", toggle=True)
+    assert (
+        manager.c.group.info()["name"] == "a"
+    )  # Toggling without switching only moves the window
+    assert manager.c.groups()["a"]["focus"] is None
+    assert manager.c.groups()["b"]["focus"] == "one"
+
+
 class BringFrontClickConfig(ManagerConfig):
     bring_front_click = True