Repository: qtile/qtile

Issue:
Allow defining named KeyChords for Chord widget without setting it up as a mode
<!--
Please do not ask general questions here! There are [community
contact](https://github.com/qtile/qtile#community) options for that.
-->

# Issue description (Feature request)

<!--
A brief discussion of what failed and how it failed. A description of
what you tried is helpful, i.e. "When I use lazy.kill() on a window I get
the following stack trace" instead of "Closing windows doesn't work".
-->

When creating a KeyChord, specifically with use of the Chord widget, I've noticed that the only way to display something with the Chord widget is to set the KeyChord up as a mode. It would be ideal to be able to name a KeyChord for display in that widget, without having the "Keep mode until Escape is pressed" functionality. 

(In my case, I would find this useful for distinguishing if I have accidentally entered a key chord, or to signify a more 'dangerous' key chord such as power management)


Discussion:
I looked through the documentation to see if this was possible to implement through a subclass of KeyChord or the Chord widget, and it seems like it *might* be possible, but I'm not sure how modes are implemented currently, and am not sure how to make a version of KeyChord with it removed.
The current behavior seems to be linked to https://github.com/qtile/qtile/blob/04885abc07c6438293c4308f944b42d3168c9dbb/libqtile/core/manager.py#L360-L361 but I have not clue if changing it and adding an 'isMode' attribute to KeyChord would affect other thing or not.

Resolving patch:
diff --git a/libqtile/config.py b/libqtile/config.py
--- a/libqtile/config.py
+++ b/libqtile/config.py
@@ -35,6 +35,7 @@
 from libqtile import configurable, hook, utils
 from libqtile.bar import Bar
 from libqtile.command.base import CommandObject
+from libqtile.log_utils import logger
 
 if TYPE_CHECKING:
     from typing import Any, Callable, ContextManager, Iterable
@@ -93,10 +94,12 @@ class KeyChord:
     submappings:
         A list of :class:`Key` or :class:`KeyChord` declarations to bind in this chord.
     mode:
-        A string with Vim-like mode name. If set, the chord mode will not be left after
-        a keystroke (except for Esc which always leaves the current chord/mode).
-        (Optional)
-
+        Boolean. Setting to ``True`` will result in the chord persisting until
+        Escape is pressed. Setting to ``False`` (default) will exit the chord once
+        the sequence has ended.
+    name:
+        A string to name the chord. The name will be displayed in the Chord
+        widget.
     """
 
     def __init__(
@@ -104,14 +107,26 @@ def __init__(
         modifiers: list[str],
         key: str,
         submappings: list[Key | KeyChord],
-        mode: str = "",
-    ) -> None:
+        mode: bool | str = False,
+        name: str = "",
+    ):
         self.modifiers = modifiers
         self.key = key
 
         submappings.append(Key([], "Escape"))
         self.submappings = submappings
         self.mode = mode
+        self.name = name
+
+        if isinstance(mode, str):
+            logger.warning(
+                "The use of `mode` to set the KeyChord name is deprecated. "
+                "Please use `name='%s'` instead. "
+                "'mode' should be a boolean value to set whether the chord is persistent (True) or not.",
+                mode,
+            )
+            self.name = mode
+            self.mode = True
 
     def __repr__(self) -> str:
         return "<KeyChord (%s, %s)>" % (self.modifiers, self.key)
diff --git a/libqtile/core/manager.py b/libqtile/core/manager.py
--- a/libqtile/core/manager.py
+++ b/libqtile/core/manager.py
@@ -394,7 +394,7 @@ def process_key_event(self, keysym: int, mask: int) -> None:
                     )
                     if status in (interface.ERROR, interface.EXCEPTION):
                         logger.error("KB command error %s: %s", cmd.name, val)
-            if self.chord_stack and (self.chord_stack[-1].mode == "" or key.key == "Escape"):
+            if self.chord_stack and (not self.chord_stack[-1].mode or key.key == "Escape"):
                 self.cmd_ungrab_chord()
             return
 
@@ -425,7 +425,7 @@ def ungrab_keys(self) -> None:
     def grab_chord(self, chord: KeyChord) -> None:
         self.chord_stack.append(chord)
         if self.chord_stack:
-            hook.fire("enter_chord", chord.mode)
+            hook.fire("enter_chord", chord.name)
 
         self.ungrab_keys()
         for key in chord.submappings:
@@ -990,11 +990,11 @@ def walk_binding(k: Key | KeyChord, mode: str) -> None:
                 )
                 return
             if isinstance(k, KeyChord):
-                new_mode_s = k.mode if k.mode else "<unnamed>"
+                new_mode_s = k.name if k.name else "<unnamed>"
                 new_mode = (
-                    k.mode
+                    k.name
                     if mode == "<root>"
-                    else "{}>{}".format(mode, k.mode if k.mode else "_")
+                    else "{}>{}".format(mode, k.name if k.name else "_")
                 )
                 rows.append([mode, name, modifiers, "", "Enter {:s} mode".format(new_mode_s)])
                 for s in k.submappings:


Test patch:
diff --git a/test/widgets/test_chord.py b/test/widgets/test_chord.py
--- a/test/widgets/test_chord.py
+++ b/test/widgets/test_chord.py
@@ -1,4 +1,29 @@
+# Copyright (c) 2020 Tycho Andersen
+# Copyright (c) 2022 elParaguayo
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import pytest
+
+import libqtile.confreader
 from libqtile import hook
+from libqtile.config import Key, KeyChord
+from libqtile.lazy import lazy
 from libqtile.widget import Chord, base
 from test.widgets.conftest import FakeBar
 
@@ -10,6 +35,42 @@
 BASE_FOREGROUND = textbox.foreground
 
 
+def no_op(*args):
+    pass
+
+
+class ChordConf(libqtile.confreader.Config):
+    auto_fullscreen = False
+    keys = [
+        KeyChord([], "a", [Key([], "b", lazy.function(no_op))], mode="persistent_chord"),
+        KeyChord(
+            [],
+            "z",
+            [
+                Key([], "b", lazy.function(no_op)),
+            ],
+            name="temporary_name",
+        ),
+        KeyChord(
+            [],
+            "y",
+            [
+                Key([], "b", lazy.function(no_op)),
+            ],
+            name="mode_true",
+            mode=True,
+        ),
+    ]
+    mouse = []
+    groups = [libqtile.config.Group("a"), libqtile.config.Group("b")]
+    layouts = [libqtile.layout.stack.Stack(num_stacks=1)]
+    floating_layout = libqtile.resources.default_config.floating_layout
+    screens = [libqtile.config.Screen(top=libqtile.bar.Bar([Chord()], 10))]
+
+
+chord_config = pytest.mark.parametrize("manager", [ChordConf], indirect=True)
+
+
 def test_chord_widget(fake_window, fake_qtile):
     chord = Chord(chords_colors={"testcolor": (RED, BLUE)})
     fakebar = FakeBar([chord], window=fake_window)
@@ -33,7 +94,7 @@ def test_chord_widget(fake_window, fake_qtile):
     assert chord.foreground == BASE_FOREGROUND
 
     # Unnamed chord so no text
-    hook.fire("enter_chord", True)
+    hook.fire("enter_chord", "")
     assert chord.text == ""
     assert chord.background == BASE_BACKGROUND
     assert chord.foreground == BASE_FOREGROUND
@@ -52,3 +113,69 @@ def test_chord_widget(fake_window, fake_qtile):
 
     # Finalize the widget to prevent segfault
     chord.finalize()
+
+
+@chord_config
+def test_chord_persistence(manager):
+    widget = manager.c.widget["chord"]
+
+    assert widget.info()["text"] == ""
+
+    # Test 1: Test persistent chord mode name
+    # Old style where mode contains text.
+    # Enter the chord
+    manager.c.simulate_keypress([], "a")
+    assert widget.info()["text"] == "persistent_chord"
+
+    # Chord has finished but mode should still be in place
+    manager.c.simulate_keypress([], "b")
+    assert widget.info()["text"] == "persistent_chord"
+
+    # Escape to leave chord
+    manager.c.simulate_keypress([], "Escape")
+    assert widget.info()["text"] == ""
+
+    # Test 2: Test persistent chord mode name
+    # New style - mode = True
+    # Enter the chord
+    manager.c.simulate_keypress([], "y")
+    assert widget.info()["text"] == "mode_true"
+
+    # Chord has finished but mode should still be in place
+    manager.c.simulate_keypress([], "b")
+    assert widget.info()["text"] == "mode_true"
+
+    # Escape to leave chord
+    manager.c.simulate_keypress([], "Escape")
+    assert widget.info()["text"] == ""
+
+    # Test 3: Test temporary chord name
+    # Enter the chord
+    manager.c.simulate_keypress([], "z")
+    assert widget.info()["text"] == "temporary_name"
+
+    # Chord has finished and should exit
+    manager.c.simulate_keypress([], "b")
+    assert widget.info()["text"] == ""
+
+    # Enter the chord
+    manager.c.simulate_keypress([], "z")
+    assert widget.info()["text"] == "temporary_name"
+
+    # Escape to cancel chord
+    manager.c.simulate_keypress([], "Escape")
+    assert widget.info()["text"] == ""
+
+
+def test_chord_mode_name_deprecation(caplog):
+    chord = KeyChord([], "a", [Key([], "b", lazy.function(no_op))], mode="persistent_chord")
+
+    assert caplog.records
+
+    log = caplog.records[0]
+    assert log.levelname == "WARNING"
+    assert "name='persistent_chord'" in log.message
+
+    # Mode should be set to True and name set to the mode name
+    assert chord.mode is True
+    assert chord.name == "persistent_chord"