Repository: DistributedProofreaders/guiguts-py
Pull request #962: Improve setting of shortcut keystrokes
Author: login: windymilla
Created: 2025-04-26T18:57:38Z | Merged: 2025-04-30T16:02:00Z

Allow user to press key with modifiers instead of using checkboxes to set the modifiers.

Should also allow use of option key on macOS.

Should address points 6 & 7 of #956 


Diff:
diff --git a/src/guiguts/application.py b/src/guiguts/application.py
index d562957e..165123bb 100644
--- a/src/guiguts/application.py
+++ b/src/guiguts/application.py
@@ -798,11 +798,11 @@ def init_bookmark_menu(self, parent: MenuMetadata) -> None:
         # Because keyboard layouts are different, need to bind to several keys for some bookmarks
         kbd = get_keyboard_layout()
         shortcuts = [
-            ("!", "!"),
-            ("@", '"'),
-            ("#", "sterling"),
-            ("$", "$"),
-            ("%", "%"),
+            ("exclam", "exclam"),
+            ("at", "quotedbl"),
+            ("numbersign", "sterling"),
+            ("dollar", "dollar"),
+            ("percent", "percent"),
         ]
         # Shift+Cmd+number no good for Mac due to clash with screen capture shortcuts
         # Ctrl+number could also clash on Mac with virtual desktop switching, but
@@ -812,7 +812,7 @@ def init_bookmark_menu(self, parent: MenuMetadata) -> None:
                 # Add tilde before one of the letters in "okmar"
                 "Set Bo" + "okmar"[: bm - 1] + "~" + "okmar"[bm - 1 :] + f"k ~{bm}",
                 lambda num=bm: self.file.set_bookmark(num),  # type:ignore[misc]
-                f"Ctrl+Key-{keys[kbd]}",
+                f"Ctrl+Shift+Key-{keys[kbd]}",
             )
 
         for bm in range(1, 6):
diff --git a/src/guiguts/html_tools.py b/src/guiguts/html_tools.py
index ab344f38..8daa6bc5 100644
--- a/src/guiguts/html_tools.py
+++ b/src/guiguts/html_tools.py
@@ -590,6 +590,7 @@ def __init__(self) -> None:
 
     def run(self) -> None:
         """Do the check and add messages to the dialog."""
+        self.dialog.reset()
 
         validator_url = "https://validator.w3.org/nu/"
 
diff --git a/src/guiguts/maintext.py b/src/guiguts/maintext.py
index bc96e075..898787f6 100644
--- a/src/guiguts/maintext.py
+++ b/src/guiguts/maintext.py
@@ -4455,15 +4455,12 @@ def set_shortcuts_from_prefs(self) -> None:
                     break
 
     def metadata_from_shortcut(self, shortcut: str) -> Optional[EntryMetadata]:
-        """Return metadata that has given shortcut assigned (or None).
-        Copes with "Key-" being present/absent."""
+        """Return metadata that has given shortcut assigned (or None)."""
         if not shortcut:
             return None
-        shortcut = shortcut.replace("Key-", "")
         all_commands = self.get_all_palette_commands()
         for command in all_commands:
-            cmd_shortcut = process_accel(command.shortcut)[0]
-            if cmd_shortcut == shortcut:
+            if process_accel(command.shortcut)[0] == shortcut:
                 return command
         return None
 
diff --git a/src/guiguts/misc_dialogs.py b/src/guiguts/misc_dialogs.py
index d805f584..440ad5f0 100644
--- a/src/guiguts/misc_dialogs.py
+++ b/src/guiguts/misc_dialogs.py
@@ -35,6 +35,7 @@
     ToolTip,
     insert_in_focus_widget,
     OkApplyCancelDialog,
+    OkCancelDialog,
     mouse_bind,
     Combobox,
     Notebook,
@@ -970,38 +971,30 @@ def __init__(self, recentness: int, entry: EntryMetadata) -> None:
         self.entry = entry
 
 
-class CommandEditDialog(OkApplyCancelDialog):
+class CommandEditDialog(OkCancelDialog):
     """Command Edit Dialog."""
 
     manual_page = "Help_Menu#User-defined_Keyboard_Shortcuts"
-    supported_keys = (
-        "F1",
-        "F2",
-        "F3",
-        "F4",
-        "F5",
-        "F6",
-        "F7",
-        "F8",
-        "F9",
-        "F10",
-        "F11",
-        "F12",
-        "Left",
-        "Right",
-        "Up",
-        "Down",
-        "Home",
-        "End",
-        "Prior",
-        "Next",
-        "Insert",
-        "sterling",
-    )
+
+    # Define which keys count as modifiers
+    MODIFIER_KEYS = {
+        "Shift_L": "Shift",
+        "Shift_R": "Shift",
+        "Control_L": "Ctrl",
+        "Control_R": "Ctrl",
+        "Alt_L": "Option" if is_mac() else "Alt",
+        "Alt_R": "Option" if is_mac() else "Alt",
+        "Meta_L": "Cmd",
+        "Meta_R": "Cmd",
+        "Option_L": "Option",
+        "Option_R": "Option",
+    }
 
     def __init__(self, command_dlg: "CommandPaletteDialog") -> None:
         """Initialize the command edit window."""
-        super().__init__("Command Edit", resize_x=False, resize_y=False)
+        super().__init__(
+            "Shortcut Edit", display_apply=False, resize_x=False, resize_y=False
+        )
 
         self.cmd_dlg = command_dlg
         ttk.Label(self.top_frame, text="Label:").grid(
@@ -1018,50 +1011,33 @@ def __init__(self, command_dlg: "CommandPaletteDialog") -> None:
         ttk.Entry(
             self.top_frame, textvariable=self.menu_variable, state="readonly", width=40
         ).grid(row=1, column=1, sticky="NSEW", pady=2)
-        shortcut_frame = ttk.LabelFrame(self.top_frame, text="Shortcut:", padding=5)
-        shortcut_frame.grid(row=2, column=0, sticky="NSEW", columnspan=2)
-        for col in range(3):
-            shortcut_frame.columnconfigure(col, weight=1)
-        self.shift_variable = tk.BooleanVar()
-        ttk.Checkbutton(
-            shortcut_frame,
-            text="Shift ⇧" if is_mac() else "Shift",
-            variable=self.shift_variable,
-            takefocus=False,
-            command=self.settings_to_shortcut,
-        ).grid(row=0, column=0, stick="NSEW")
-        self.control_variable = tk.BooleanVar()
-        ttk.Checkbutton(
-            shortcut_frame,
-            text="Ctrl ⌃" if is_mac() else "Ctrl",
-            variable=self.control_variable,
-            takefocus=False,
-            command=self.settings_to_shortcut,
-        ).grid(row=0, column=1, stick="NSEW")
-        self.alt_command_variable = tk.BooleanVar()
-        ttk.Checkbutton(
-            shortcut_frame,
-            text="Command ⌘" if is_mac() else "Alt",
-            variable=self.alt_command_variable,
-            takefocus=False,
-            command=self.settings_to_shortcut,
-        ).grid(row=0, column=2, stick="NSEW")
+        ttk.Label(self.top_frame, text="Shortcut:").grid(
+            row=2, column=0, sticky="NSEW", pady=2
+        )
         self._shortcut = ""
         self.shortcut_variable = tk.StringVar()  # For display only
         shortcut_entry = ttk.Entry(
-            shortcut_frame,
+            self.top_frame,
             textvariable=self.shortcut_variable,
             state="readonly",
         )
-        shortcut_entry.grid(row=1, column=0, sticky="NSEW", columnspan=3)
-        alt_cmd_text = "Command" if is_mac() else "Alt"
+        shortcut_entry.grid(row=2, column=1, sticky="NSEW", pady=2)
         ToolTip(
             shortcut_entry,
-            f"Set Shift/Ctrl/{alt_cmd_text} and press keyboard key to use for shortcut.",
+            "Press required modifiers and keyboard key to use for shortcut",
         )
-        self.bind("<Key>", self.do_key)
+
+        # Just used for test binding
+        self.dummy_widget = ttk.Label(self.top_frame, takefocus=False)
+
+        # Track which modifier keys are currently pressed
+        self.pressed_modifiers: set[str] = set()
+        self.bind("<KeyPress>", self.key_press)
+        self.bind("<KeyRelease>", self.key_release)
+        # Clear modifiers if dialog loses focus, particularly via Alt-tab on Windows
+        self.bind("<FocusOut>", lambda _: self.pressed_modifiers.clear())
+
         self.cmd = EntryMetadata("", "", "")
-        self.key = ""
 
     @property
     def shortcut(self) -> str:
@@ -1071,7 +1047,8 @@ def shortcut(self) -> str:
     @shortcut.setter
     def shortcut(self, value: str) -> None:
         self._shortcut = value
-        self.shortcut_variable.set(process_accel(value)[0])
+        display_shortcut = process_accel(value)[0]
+        self.shortcut_variable.set(display_shortcut)
 
     def load(self, cmd: EntryMetadata) -> None:
         """Load dialog with values from given command."""
@@ -1079,7 +1056,6 @@ def load(self, cmd: EntryMetadata) -> None:
         self.label_variable.set(self.cmd.display_label())
         self.menu_variable.set(self.cmd.display_parent_label())
         self.shortcut = self.cmd.shortcut
-        self.shortcut_to_settings()
 
     def apply_changes(self) -> bool:
         """Save shortcut from dialog into current cmd.
@@ -1088,31 +1064,81 @@ def apply_changes(self) -> bool:
             True if successful.
         """
         new_shortcut = self.shortcut
-        single_char_keyname = len(re.sub(r".*\+", "", new_shortcut)) == 1
-        if single_char_keyname and not (
-            self.control_variable.get() or self.alt_command_variable.get()
+        display_shortcut = process_accel(new_shortcut)[0]
+        # Don't allow shortcuts that don't use Ctrl/Cmd/Alt except for F keys
+        if (
+            display_shortcut
+            and not any(m in display_shortcut for m in ["Ctrl", "Cmd", "Alt"])
+            and not re.search(r"F\d+$", display_shortcut)
         ):
             logger.error(
-                f"Shortcut must include Ctrl or {'Command' if is_mac() else 'Alt'}"
+                f"Shortcut must include Ctrl or {'Cmd' if is_mac() else 'Alt'}"
             )
+            self.lift()
+            self.focus()
             return False
-        if new_shortcut == "F1":
+
+        # Don't allow shortcuts that use Option
+        if "Option" in display_shortcut:
+            logger.error("Option key may not be used for shortcuts")
+            self.lift()
+            self.focus()
+            return False
+
+        # Plain F1 is reserved
+        if display_shortcut == "F1":
             logger.error(
-                f"F1 without Shift, Ctrl or {'Command' if is_mac() else 'Alt'} is reserved for Help"
+                f"F1 without Shift, Ctrl or {'Cmd' if is_mac() else 'Alt'} is reserved for Help"
             )
+            self.lift()
+            self.focus()
+            return False
+
+        # Cmd+Shift+? is reserved for Help too
+        if display_shortcut == "Cmd+Shift+?":
+            logger.error("Cmd+Shift+? is reserved for Help")
+            self.lift()
+            self.focus()
             return False
 
+        # Other reserved shortcuts
+        ctrl_cmd = "Cmd" if is_mac() else "Ctrl"
+        for res_key in ("A", "C", "V", "X"):
+            if display_shortcut == f"{ctrl_cmd}+{res_key}":
+                logger.error(
+                    f"{display_shortcut} is a reserved shortcut and may not be reassigned"
+                )
+                self.lift()
+                self.focus()
+                return False
+
+        # Bind do_nothing to dummy widget, to test if the bind sequence is legal
+        def do_nothing(_: tk.Event) -> None:
+            """Do nothing."""
+
+        if new_shortcut:
+            try:
+                test_key = process_accel(new_shortcut)[1]
+                self.dummy_widget.bind(test_key, do_nothing)
+                self.dummy_widget.unbind(test_key)
+            except tk.TclError:
+                logger.error(
+                    f"Key combination {test_key} is not supported as a shortcut."
+                )
+                self.lift()
+                self.focus()
+                return False
+
         shortcuts_dict = KeyboardShortcutsDict()
 
         menu = self.menu_variable.get()
         label = self.label_variable.get()
         new_assign = f"{menu}|{label}" if menu else label
-        # Add "Key-" so that digits work correctly ("<Control-1>" means Control+Button-1)
-        if single_char_keyname:
-            new_shortcut = f"{new_shortcut[:-1]}Key-{new_shortcut[-1]}"
 
         # If shortcut is already assigned, check whether user wants to continue
-        command = menubar_metadata().metadata_from_shortcut(new_shortcut)
+        command = menubar_metadata().metadata_from_shortcut(
+            process_accel(new_shortcut)[0]
+        )
         if command is not None:
             menu = command.display_parent_label()
             label = command.display_label()
@@ -1146,45 +1172,31 @@ def apply_changes(self) -> bool:
         CommandPaletteDialog.recreate_menus_callback()  # pylint: disable=not-callable
         return True
 
-    def do_key(self, event: tk.Event) -> None:
+    def key_press(self, event: tk.Event) -> str:
         """Handle keystroke in dialog."""
-        if event.keysym in ("BackSpace", "Delete"):
-            self.key = ""
-        elif event.keysym in CommandEditDialog.supported_keys:
-            self.key = event.keysym
+        keysym = event.keysym
+        if keysym in self.MODIFIER_KEYS:
+            self.pressed_modifiers.add(keysym)
         elif (
-            event.char
-            and event.char.isascii()
-            and event.char.isprintable()
-            and not event.char.isspace()
+            event.keysym in ("BackSpace", "Delete") and len(self.pressed_modifiers) == 0
         ):
-            self.key = event.char.upper()
-        self.settings_to_shortcut()
-
-    def shortcut_to_settings(self) -> None:
-        """Update dialog settings based on self.shortcut."""
-        shortcut = self.shortcut_variable.get()
-        self.shift_variable.set("Shift" in shortcut)
-        self.control_variable.set("Ctrl" in shortcut)
-        self.alt_command_variable.set("Alt" in shortcut or "Cmd" in shortcut)
-        # Key needs to use original shortcut, not shortcut_variable
-        # which has been processed for display, e.g. "sterling"=>"£"
-        self.key = re.sub(r".*\+", "", self.shortcut)
-
-    def settings_to_shortcut(self) -> None:
-        """Set self.shortcut based on checkbutton settings."""
-        if not self.key:
             self.shortcut = ""
-            return
-        shortcut = ""
-        if self.control_variable.get():
-            shortcut += "Ctrl+"
-        if self.shift_variable.get():
-            shortcut += "Shift+"
-        if self.alt_command_variable.get():
-            shortcut += "Cmd+" if is_mac() else "Alt+"
-        shortcut += self.key
-        self.shortcut = shortcut
+        else:
+            # Combine the current modifiers with the key
+            mods = sorted(set(self.MODIFIER_KEYS[kk] for kk in self.pressed_modifiers))
+            # Regular character key
+            if len(keysym) == 1:
+                keysym = f"Key-{keysym.upper()}"
+            # Combine modifiers & key to create shortcut
+            self.shortcut = "+".join(mods + [keysym])
+        return "break"
+
+    def key_release(self, event: tk.Event) -> str:
+        """Handle key release in dialog."""
+        key = event.keysym
+        if key in self.MODIFIER_KEYS:
+            self.pressed_modifiers.discard(key)
+        return "break"
 
 
 class CommandPaletteDialog(ToplevelDialog):
@@ -1291,6 +1303,11 @@ def store_recreate_menus_callback(cls, callback: Callable) -> None:
         """Store function to be called to recreate menus."""
         cls.recreate_menus_callback = callback
 
+    def grab_focus(self) -> None:
+        """Override grabbing focus to set focus to Entry field."""
+        super().grab_focus()
+        self.entry.focus()
+
     def update_list(self) -> None:
         """Update the command list based on search input."""
 
@@ -1466,12 +1483,16 @@ def move_in_list(self, direction: int) -> str:
     def handle_list_typing(self, event: tk.Event) -> None:
         """Handle key press in the list to simulate typing in the Entry box."""
         current_text = self.search_var.get()
+        # If (some) modifier keys pressed with character when typing in list,
+        # don't add the char to the entry field:
+        # Shift, Caps Lock, etc. are OK, but not Ctrl, Cmd, Alt (platform-specific)
+        bad_modifiers = 0x0004 | 0x0008 | 0x0080 | 0x20000
+        state = int(event.state)
         if event.keysym in ("BackSpace", "Delete"):
             self.search_var.set(current_text[:-1])
             self.update_list()  # Update the list based on the new search text
-        elif (
-            event.char and event.char.isprintable()
-        ):  # If a proper char, add it to the entry
+        elif event.char and event.char.isprintable() and not state & bad_modifiers:
+            # If a proper char, add it to the entry
             self.search_var.set(current_text + event.char)
             self.update_list()  # Update the list based on the new search text
 
diff --git a/src/guiguts/utilities.py b/src/guiguts/utilities.py
index 29f20390..15282b64 100644
--- a/src/guiguts/utilities.py
+++ b/src/guiguts/utilities.py
@@ -22,6 +22,69 @@
 # See: https://pytest.org/en/7.4.x/example/simple.html#detect-if-running-from-within-a-pytest-run
 CALLED_FROM_TEST = False
 
+# Event keysym names as keys, display names as values
+SUPPORTED_SHORTCUT_KEYS = {
+    "F1": "F1",
+    "F2": "F2",
+    "F3": "F3",
+    "F4": "F4",
+    "F5": "F5",
+    "F6": "F6",
+    "F7": "F7",
+    "F8": "F8",
+    "F9": "F9",
+    "F10": "F10",
+    "F11": "F11",
+    "F12": "F12",
+    "Left": "Left",
+    "Right": "Right",
+    "Up": "Up",
+    "Down": "Down",
+    "Home": "Home",
+    "End": "End",
+    "Prior": "PgUp",
+    "Next": "PgDn",
+    "Insert": "Insert",
+    "exclam": "!",
+    "quotedbl": '"',
+    "numbersign": "#",
+    "dollar": "$",
+    "percent": "%",
+    "ampersand": "&",
+    "quoteright": "'",
+    "parenleft": "(",
+    "parenright": ")",
+    "asterisk": "*",
+    "plus": "+",
+    "comma": ",",
+    "minus": "-",
+    "period": ".",
+    "slash": "/",
+    "colon": ":",
+    "semicolon": ";",
+    "less": "<",
+    "equal": "=",
+    "greater": ">",
+    "question": "?",
+    "at": "@",
+    "bracketleft": "[",
+    "backslash": "\\",
+    "bracketright": "]",
+    "asciicircum": "^",
+    "underscore": "_",
+    "quoteleft": "`",
+    "braceleft": "{",
+    "bar": "|",
+    "braceright": "}",
+    "asciitilde": "~",
+    "sterling": "£",
+    "notsign": "¬",
+    "grave": "`",
+    "apostrophe": "'",
+    "plusminus": "±",
+    "section": "§",
+}
+
 
 #
 # Functions to check which OS is being used
@@ -273,9 +336,8 @@ def process_accel(accel: str) -> tuple[str, str]:
         keyevent = keyevent.replace("Alt+", "Alt-")
     accel = accel.replace("Key-", "")
     # More friendly names for display
-    accel = accel.replace("sterling", "£")
-    accel = accel.replace("Prior", "PgUp")
-    accel = accel.replace("Next", "PgDn")
+    for key, display in SUPPORTED_SHORTCUT_KEYS.items():
+        accel = accel.replace(key, display)
     return (accel, f"<{keyevent}>")
 
 
@@ -305,15 +367,20 @@ def get_keyboard_layout() -> int:
         try:
             result = subprocess.run(
                 [
-                    "osascript",
-                    "-e",
-                    'tell application "System Events" to get name of current keyboard layout',
+                    "defaults",
+                    "read",
+                    "~/Library/Preferences/com.apple.HIToolbox.plist",
+                    "AppleSelectedInputSources",
                 ],
                 capture_output=True,
                 text=True,
                 check=False,
             )
-            layout = result.stdout.strip()  # e.g. "British" or "U.S."
+            for line in result.stdout.splitlines():
+                if "KeyboardLayout Name" in line:
+                    # May begin with "British" or "U.S." or "US"
+                    layout = re.sub(r'.*"([^"]+)";$', r"\1", line)
+                    break
         except subprocess.SubprocessError:
             pass
     else:
@@ -327,7 +394,7 @@ def get_keyboard_layout() -> int:
                     break
         except subprocess.SubprocessError:
             pass
-    return 1 if layout in ("00000809", "gb", "British") else 0
+    return 1 if layout.startswith(("00000809", "gb", "British")) else 0
 
 
 class DiacriticRemover:
diff --git a/src/guiguts/widgets.py b/src/guiguts/widgets.py
index 11eb5665..8f4700a8 100644
--- a/src/guiguts/widgets.py
+++ b/src/guiguts/widgets.py
@@ -130,9 +130,10 @@ def show_dialog(
             dlg.deiconify()
             dlg.reset()
         # Or do we need to create it?
-        else:
+        if not cls.get_dialog():
             dlg = cls(**kwargs)
         ToplevelDialog._toplevel_dialogs[dlg_name] = dlg
+        dlg.grab_focus()
         return dlg
 
     @classmethod
@@ -158,6 +159,11 @@ def get_mark_prefix(cls) -> str:
         """
         return cls.__name__.removesuffix("Dialog")
 
+    def grab_focus(self) -> None:
+        """Grab focus and raise to top (if permitted by window manager)."""
+        if is_x11():
+            grab_focus(self)
+
     def got_focus(self) -> None:
         """Called when dialog gets focus."""
         assert self.manual_page is not None
@@ -336,38 +342,44 @@ def show_manual_page(cls) -> None:
 class OkApplyCancelDialog(ToplevelDialog):
     """A ToplevelDialog with OK, Apply & Cancel buttons."""
 
-    def __init__(self, title: str, **kwargs: Any) -> None:
-        """Initialize the dialog."""
+    def __init__(self, title: str, display_apply: bool = True, **kwargs: Any) -> None:
+        """Initialize the dialog.
+
+        Args:
+            title: Title for dialog.
+            display_apply: Set to False if Apply button not required.
+        """
         super().__init__(title, **kwargs)
-        button_frame = ttk.Frame(self, padding=5)
-        button_frame.grid(row=1, column=0, sticky="NSEW")
-        button_frame.columnconfigure(0, weight=1)
-        ok_button = ttk.Button(
+        outer_button_frame = ttk.Frame(self, padding=5)
+        outer_button_frame.grid(row=1, column=0, sticky="NSEW")
+        outer_button_frame.columnconfigure(0, weight=1)
+        button_frame = ttk.Frame(outer_button_frame)
+        button_frame.grid(row=1, column=0)
+        column = 0
+        ttk.Button(
             button_frame,
             text="OK",
             default="active",
             command=self.ok_pressed,
             takefocus=False,
-        )
-        ok_button.grid(row=0, column=0)
-        button_frame.columnconfigure(1, weight=1)
-        apply_button = ttk.Button(
-            button_frame,
-            text="Apply",
-            default="normal",
-            command=self.apply_changes,
-            takefocus=False,
-        )
-        apply_button.grid(row=0, column=1)
-        button_frame.columnconfigure(2, weight=1)
-        cancel_button = ttk.Button(
+        ).grid(row=0, column=column, padx=5)
+        if display_apply:
+            column += 1
+            ttk.Button(
+                button_frame,
+                text="Apply",
+                default="normal",
+                command=self.apply_changes,
+                takefocus=False,
+            ).grid(row=0, column=column, padx=5)
+        column += 1
+        ttk.Button(
             button_frame,
             text="Cancel",
             default="normal",
             command=self.cancel_pressed,
             takefocus=False,
-        )
-        cancel_button.grid(row=0, column=2)
+        ).grid(row=0, column=column, padx=5)
         self.bind("<Return>", lambda event: self.ok_pressed())
         self.bind("<Escape>", lambda event: self.cancel_pressed())
 
@@ -392,6 +404,19 @@ def cancel_pressed(self) -> None:
         self.destroy()
 
 
+class OkCancelDialog(OkApplyCancelDialog):
+    """A ToplevelDialog with OK & Cancel buttons."""
+
+    def __init__(self, title: str, **kwargs: Any) -> None:
+        """Initialize the dialog.
+
+        Args:
+            title: Title for dialog.
+        """
+        kwargs["display_apply"] = False
+        super().__init__(title, **kwargs)
+
+
 class Combobox(ttk.Combobox):
     """A ttk Combobox with some convenience functions.
 


Reviews:
user: login: tangledhelix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T05:57:10Z
user: login: tangledhelix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T05:59:50Z
user: login: srjfoo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T06:23:30Z
user: login: windymilla
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-27T06:53:12Z
user: login: windymilla
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-27T06:56:41Z
user: login: windymilla
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-27T06:58:27Z
user: login: windymilla
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-27T06:59:20Z
user: login: windymilla
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-27T11:59:56Z
user: login: windymilla
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-27T13:08:28Z
user: login: srjfoo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T17:06:43Z
user: login: tangledhelix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T04:23:12Z
user: login: windymilla
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T12:42:48Z
user: login: srjfoo
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-30T06:37:31Z

Review comments:
diff_hunk: @@ -1146,45 +1118,27 @@ def apply_changes(self) -> bool:
         CommandPaletteDialog.recreate_menus_callback()  # pylint: disable=not-callable
         return True
 
-    def do_key(self, event: tk.Event) -> None:
+    def key_press(self, event: tk.Event) -> str:
         """Handle keystroke in dialog."""
-        if event.keysym in ("BackSpace", "Delete"):
-            self.key = ""
-        elif event.keysym in CommandEditDialog.supported_keys:
-            self.key = event.keysym
-        elif (
-            event.char
-            and event.char.isascii()
-            and event.char.isprintable()
-            and not event.char.isspace()
-        ):
-            self.key = event.char.upper()
-        self.settings_to_shortcut()
-
-    def shortcut_to_settings(self) -> None:
-        """Update dialog settings based on self.shortcut."""
-        shortcut = self.shortcut_variable.get()
-        self.shift_variable.set("Shift" in shortcut)
-        self.control_variable.set("Ctrl" in shortcut)
-        self.alt_command_variable.set("Alt" in shortcut or "Cmd" in shortcut)
-        # Key needs to use original shortcut, not shortcut_variable
-        # which has been processed for display, e.g. "sterling"=>"£"
-        self.key = re.sub(r".*\+", "", self.shortcut)
-
-    def settings_to_shortcut(self) -> None:
-        """Set self.shortcut based on checkbutton settings."""
-        if not self.key:
-            self.shortcut = ""
-            return
-        shortcut = ""
-        if self.control_variable.get():
-            shortcut += "Ctrl+"
-        if self.shift_variable.get():
-            shortcut += "Shift+"
-        if self.alt_command_variable.get():
-            shortcut += "Cmd+" if is_mac() else "Alt+"
-        shortcut += self.key
-        self.shortcut = shortcut
+        keysym = event.keysym
+        if keysym in self.MODIFIER_KEYS:
path: src/guiguts/misc_dialogs.py
user: login: tangledhelix
body: Cmd, Ctrl, Shift seem to work as expected on Mac.

Option isn't working. Here you are collecting a chord of keys, and then storing the modifier key(s) before storing the base key. But on Mac, `Opt-c` (where `c` is a non-modifier key) is most often used to input special characters (for example º ™ ¢ € and so on). When I try to use Opt in a chord, I believe it is sending that special character rather than the base character. (Yet, at the same time, it stores Opt as a modifier.)

So for example when I try to bind `Cmd-Opt-k` to a command, it displays this in the dialog:

`Cmd+Option+??`

When I try to save this, an exception is thrown. So I can't save it. I believe it's actually trying to store `Cmd-Opt-˚` where ˚ is the character produced by pressing Opt-k (at least in the US layout I'm using).

Trying with only Opt and no other modifier is even stranger looking. Opt-d as a bind is showing in the dialog as: `Option+partialderiv@ive` (Opt-d produces ∂)

Others are similarly odd, Opt-p `Option+Greek_pi`, Opt-t `Option+dagger` and so on. Some of these actually work when you use them (such as Opt-t) and others don't because you can't even save them, as noted above.
created_at: 2025-04-27T05:46:55Z
author_association: MEMBER
diff_hunk: @@ -1146,45 +1118,27 @@ def apply_changes(self) -> bool:
         CommandPaletteDialog.recreate_menus_callback()  # pylint: disable=not-callable
         return True
 
-    def do_key(self, event: tk.Event) -> None:
+    def key_press(self, event: tk.Event) -> str:
         """Handle keystroke in dialog."""
-        if event.keysym in ("BackSpace", "Delete"):
-            self.key = ""
-        elif event.keysym in CommandEditDialog.supported_keys:
-            self.key = event.keysym
-        elif (
-            event.char
-            and event.char.isascii()
-            and event.char.isprintable()
-            and not event.char.isspace()
-        ):
-            self.key = event.char.upper()
-        self.settings_to_shortcut()
-
-    def shortcut_to_settings(self) -> None:
-        """Update dialog settings based on self.shortcut."""
-        shortcut = self.shortcut_variable.get()
-        self.shift_variable.set("Shift" in shortcut)
-        self.control_variable.set("Ctrl" in shortcut)
-        self.alt_command_variable.set("Alt" in shortcut or "Cmd" in shortcut)
-        # Key needs to use original shortcut, not shortcut_variable
-        # which has been processed for display, e.g. "sterling"=>"£"
-        self.key = re.sub(r".*\+", "", self.shortcut)
-
-    def settings_to_shortcut(self) -> None:
-        """Set self.shortcut based on checkbutton settings."""
-        if not self.key:
-            self.shortcut = ""
-            return
-        shortcut = ""
-        if self.control_variable.get():
-            shortcut += "Ctrl+"
-        if self.shift_variable.get():
-            shortcut += "Shift+"
-        if self.alt_command_variable.get():
-            shortcut += "Cmd+" if is_mac() else "Alt+"
-        shortcut += self.key
-        self.shortcut = shortcut
+        keysym = event.keysym
+        if keysym in self.MODIFIER_KEYS:
path: src/guiguts/misc_dialogs.py
user: login: tangledhelix
body: And wow, just realizing that it took ∂, which is partial derivative, and somehow it replaced the "at" buried in that word with an @ ?? Bizarre! Is that your code doing that somewhere or is tkinter or Python doing it on its own?
created_at: 2025-04-27T05:59:50Z
author_association: MEMBER
diff_hunk: @@ -1146,45 +1118,27 @@ def apply_changes(self) -> bool:
         CommandPaletteDialog.recreate_menus_callback()  # pylint: disable=not-callable
         return True
 
-    def do_key(self, event: tk.Event) -> None:
+    def key_press(self, event: tk.Event) -> str:
         """Handle keystroke in dialog."""
-        if event.keysym in ("BackSpace", "Delete"):
-            self.key = ""
-        elif event.keysym in CommandEditDialog.supported_keys:
-            self.key = event.keysym
-        elif (
-            event.char
-            and event.char.isascii()
-            and event.char.isprintable()
-            and not event.char.isspace()
-        ):
-            self.key = event.char.upper()
-        self.settings_to_shortcut()
-
-    def shortcut_to_settings(self) -> None:
-        """Update dialog settings based on self.shortcut."""
-        shortcut = self.shortcut_variable.get()
-        self.shift_variable.set("Shift" in shortcut)
-        self.control_variable.set("Ctrl" in shortcut)
-        self.alt_command_variable.set("Alt" in shortcut or "Cmd" in shortcut)
-        # Key needs to use original shortcut, not shortcut_variable
-        # which has been processed for display, e.g. "sterling"=>"£"
-        self.key = re.sub(r".*\+", "", self.shortcut)
-
-    def settings_to_shortcut(self) -> None:
-        """Set self.shortcut based on checkbutton settings."""
-        if not self.key:
-            self.shortcut = ""
-            return
-        shortcut = ""
-        if self.control_variable.get():
-            shortcut += "Ctrl+"
-        if self.shift_variable.get():
-            shortcut += "Shift+"
-        if self.alt_command_variable.get():
-            shortcut += "Cmd+" if is_mac() else "Alt+"
-        shortcut += self.key
-        self.shortcut = shortcut
+        keysym = event.keysym
+        if keysym in self.MODIFIER_KEYS:
path: src/guiguts/misc_dialogs.py
user: login: srjfoo
body: I'm trying to do a cross-reference for all the keys showing what I see as the shortcut for Option and Option+shift (since one of the questions is whether Option is going to be useful. If it's useful at all, it should be used only in combination with ctrl or cmd, because of the dead key combinations., and what the keyboard actually types, but it's taking longer than I expected, and (and there were also distractions).

I was puzzled by the ??, too -- and there are several of them.

And yes, I get an exception if I try to assign one of those. But I can assign some of the option keys and get them to work -- I just have to put the edit shortcut dialog in the background, apparently.
created_at: 2025-04-27T06:23:30Z
author_association: MEMBER
diff_hunk: @@ -1146,45 +1118,27 @@ def apply_changes(self) -> bool:
         CommandPaletteDialog.recreate_menus_callback()  # pylint: disable=not-callable
         return True
 
-    def do_key(self, event: tk.Event) -> None:
+    def key_press(self, event: tk.Event) -> str:
         """Handle keystroke in dialog."""
-        if event.keysym in ("BackSpace", "Delete"):
-            self.key = ""
-        elif event.keysym in CommandEditDialog.supported_keys:
-            self.key = event.keysym
-        elif (
-            event.char
-            and event.char.isascii()
-            and event.char.isprintable()
-            and not event.char.isspace()
-        ):
-            self.key = event.char.upper()
-        self.settings_to_shortcut()
-
-    def shortcut_to_settings(self) -> None:
-        """Update dialog settings based on self.shortcut."""
-        shortcut = self.shortcut_variable.get()
-        self.shift_variable.set("Shift" in shortcut)
-        self.control_variable.set("Ctrl" in shortcut)
-        self.alt_command_variable.set("Alt" in shortcut or "Cmd" in shortcut)
-        # Key needs to use original shortcut, not shortcut_variable
-        # which has been processed for display, e.g. "sterling"=>"£"
-        self.key = re.sub(r".*\+", "", self.shortcut)
-
-    def settings_to_shortcut(self) -> None:
-        """Set self.shortcut based on checkbutton settings."""
-        if not self.key:
-            self.shortcut = ""
-            return
-        shortcut = ""
-        if self.control_variable.get():
-            shortcut += "Ctrl+"
-        if self.shift_variable.get():
-            shortcut += "Shift+"
-        if self.alt_command_variable.get():
-            shortcut += "Cmd+" if is_mac() else "Alt+"
-        shortcut += self.key
-        self.shortcut = shortcut
+        keysym = event.keysym
+        if keysym in self.MODIFIER_KEYS:
path: src/guiguts/misc_dialogs.py
user: login: windymilla
body: AFK, but a couple of comments. Tkinter seems to receive the character that would appear when typing, eg percent for shift 5, rather than 5, or other rarer keys if you use Opt. That's the case generally, not specifically to do with Opt.

My code isn't converting at to @

@srjfoo as I mentioned before, if you want the user to be able to type the full shortcut with modifiers in the edit dialog, you don't want it to do the action. That's why you can't do the action when the edit dialog is in the foreground. If it wasn't like that, then when you type the shortcut, trying to set it, it would do whatever the current action was.

created_at: 2025-04-27T06:53:12Z
author_association: COLLABORATOR
diff_hunk: @@ -1146,45 +1118,27 @@ def apply_changes(self) -> bool:
         CommandPaletteDialog.recreate_menus_callback()  # pylint: disable=not-callable
         return True
 
-    def do_key(self, event: tk.Event) -> None:
+    def key_press(self, event: tk.Event) -> str:
         """Handle keystroke in dialog."""
-        if event.keysym in ("BackSpace", "Delete"):
-            self.key = ""
-        elif event.keysym in CommandEditDialog.supported_keys:
-            self.key = event.keysym
-        elif (
-            event.char
-            and event.char.isascii()
-            and event.char.isprintable()
-            and not event.char.isspace()
-        ):
-            self.key = event.char.upper()
-        self.settings_to_shortcut()
-
-    def shortcut_to_settings(self) -> None:
-        """Update dialog settings based on self.shortcut."""
-        shortcut = self.shortcut_variable.get()
-        self.shift_variable.set("Shift" in shortcut)
-        self.control_variable.set("Ctrl" in shortcut)
-        self.alt_command_variable.set("Alt" in shortcut or "Cmd" in shortcut)
-        # Key needs to use original shortcut, not shortcut_variable
-        # which has been processed for display, e.g. "sterling"=>"£"
-        self.key = re.sub(r".*\+", "", self.shortcut)
-
-    def settings_to_shortcut(self) -> None:
-        """Set self.shortcut based on checkbutton settings."""
-        if not self.key:
-            self.shortcut = ""
-            return
-        shortcut = ""
-        if self.control_variable.get():
-            shortcut += "Ctrl+"
-        if self.shift_variable.get():
-            shortcut += "Shift+"
-        if self.alt_command_variable.get():
-            shortcut += "Cmd+" if is_mac() else "Alt+"
-        shortcut += self.key
-        self.shortcut = shortcut
+        keysym = event.keysym
+        if keysym in self.MODIFIER_KEYS:
path: src/guiguts/misc_dialogs.py
user: login: windymilla
body: Overall, it seems to me as though Option is causing more problems than solutions, but it's your call if you want to persevere with it. Personally I think there are enough combinations with 3 modifier keys, like on Windows 
created_at: 2025-04-27T06:56:41Z
author_association: COLLABORATOR
diff_hunk: @@ -1146,45 +1118,27 @@ def apply_changes(self) -> bool:
         CommandPaletteDialog.recreate_menus_callback()  # pylint: disable=not-callable
         return True
 
-    def do_key(self, event: tk.Event) -> None:
+    def key_press(self, event: tk.Event) -> str:
         """Handle keystroke in dialog."""
-        if event.keysym in ("BackSpace", "Delete"):
-            self.key = ""
-        elif event.keysym in CommandEditDialog.supported_keys:
-            self.key = event.keysym
-        elif (
-            event.char
-            and event.char.isascii()
-            and event.char.isprintable()
-            and not event.char.isspace()
-        ):
-            self.key = event.char.upper()
-        self.settings_to_shortcut()
-
-    def shortcut_to_settings(self) -> None:
-        """Update dialog settings based on self.shortcut."""
-        shortcut = self.shortcut_variable.get()
-        self.shift_variable.set("Shift" in shortcut)
-        self.control_variable.set("Ctrl" in shortcut)
-        self.alt_command_variable.set("Alt" in shortcut or "Cmd" in shortcut)
-        # Key needs to use original shortcut, not shortcut_variable
-        # which has been processed for display, e.g. "sterling"=>"£"
-        self.key = re.sub(r".*\+", "", self.shortcut)
-
-    def settings_to_shortcut(self) -> None:
-        """Set self.shortcut based on checkbutton settings."""
-        if not self.key:
-            self.shortcut = ""
-            return
-        shortcut = ""
-        if self.control_variable.get():
-            shortcut += "Ctrl+"
-        if self.shift_variable.get():
-            shortcut += "Shift+"
-        if self.alt_command_variable.get():
-            shortcut += "Cmd+" if is_mac() else "Alt+"
-        shortcut += self.key
-        self.shortcut = shortcut
+        keysym = event.keysym
+        if keysym in self.MODIFIER_KEYS:
path: src/guiguts/misc_dialogs.py
user: login: windymilla
body: Or it could be documented that only some option combinations work, of course with the code trapping exceptions 
created_at: 2025-04-27T06:58:27Z
author_association: COLLABORATOR
diff_hunk: @@ -1146,45 +1118,27 @@ def apply_changes(self) -> bool:
         CommandPaletteDialog.recreate_menus_callback()  # pylint: disable=not-callable
         return True
 
-    def do_key(self, event: tk.Event) -> None:
+    def key_press(self, event: tk.Event) -> str:
         """Handle keystroke in dialog."""
-        if event.keysym in ("BackSpace", "Delete"):
-            self.key = ""
-        elif event.keysym in CommandEditDialog.supported_keys:
-            self.key = event.keysym
-        elif (
-            event.char
-            and event.char.isascii()
-            and event.char.isprintable()
-            and not event.char.isspace()
-        ):
-            self.key = event.char.upper()
-        self.settings_to_shortcut()
-
-    def shortcut_to_settings(self) -> None:
-        """Update dialog settings based on self.shortcut."""
-        shortcut = self.shortcut_variable.get()
-        self.shift_variable.set("Shift" in shortcut)
-        self.control_variable.set("Ctrl" in shortcut)
-        self.alt_command_variable.set("Alt" in shortcut or "Cmd" in shortcut)
-        # Key needs to use original shortcut, not shortcut_variable
-        # which has been processed for display, e.g. "sterling"=>"£"
-        self.key = re.sub(r".*\+", "", self.shortcut)
-
-    def settings_to_shortcut(self) -> None:
-        """Set self.shortcut based on checkbutton settings."""
-        if not self.key:
-            self.shortcut = ""
-            return
-        shortcut = ""
-        if self.control_variable.get():
-            shortcut += "Ctrl+"
-        if self.shift_variable.get():
-            shortcut += "Shift+"
-        if self.alt_command_variable.get():
-            shortcut += "Cmd+" if is_mac() else "Alt+"
-        shortcut += self.key
-        self.shortcut = shortcut
+        keysym = event.keysym
+        if keysym in self.MODIFIER_KEYS:
path: src/guiguts/misc_dialogs.py
user: login: windymilla
body: Or maybe there's a way to get the raw keystroke, not the char or charsym that GG receives
created_at: 2025-04-27T06:59:20Z
author_association: COLLABORATOR
diff_hunk: @@ -1146,45 +1118,27 @@ def apply_changes(self) -> bool:
         CommandPaletteDialog.recreate_menus_callback()  # pylint: disable=not-callable
         return True
 
-    def do_key(self, event: tk.Event) -> None:
+    def key_press(self, event: tk.Event) -> str:
         """Handle keystroke in dialog."""
-        if event.keysym in ("BackSpace", "Delete"):
-            self.key = ""
-        elif event.keysym in CommandEditDialog.supported_keys:
-            self.key = event.keysym
-        elif (
-            event.char
-            and event.char.isascii()
-            and event.char.isprintable()
-            and not event.char.isspace()
-        ):
-            self.key = event.char.upper()
-        self.settings_to_shortcut()
-
-    def shortcut_to_settings(self) -> None:
-        """Update dialog settings based on self.shortcut."""
-        shortcut = self.shortcut_variable.get()
-        self.shift_variable.set("Shift" in shortcut)
-        self.control_variable.set("Ctrl" in shortcut)
-        self.alt_command_variable.set("Alt" in shortcut or "Cmd" in shortcut)
-        # Key needs to use original shortcut, not shortcut_variable
-        # which has been processed for display, e.g. "sterling"=>"£"
-        self.key = re.sub(r".*\+", "", self.shortcut)
-
-    def settings_to_shortcut(self) -> None:
-        """Set self.shortcut based on checkbutton settings."""
-        if not self.key:
-            self.shortcut = ""
-            return
-        shortcut = ""
-        if self.control_variable.get():
-            shortcut += "Ctrl+"
-        if self.shift_variable.get():
-            shortcut += "Shift+"
-        if self.alt_command_variable.get():
-            shortcut += "Cmd+" if is_mac() else "Alt+"
-        shortcut += self.key
-        self.shortcut = shortcut
+        keysym = event.keysym
+        if keysym in self.MODIFIER_KEYS:
path: src/guiguts/misc_dialogs.py
user: login: windymilla
body: > ... is whether Option is going to be useful. If it's useful at all, it should be used only in combination with ctrl or cmd, because of the dead key combinations., and what the keyboard actually types

The latest commit enforces that. Every shortcut must contain at least one of Ctrl, Cmd, Alt. 

> But I can assign some of the option keys and get them to work -- I just have to put the edit shortcut dialog in the background, apparently.

Hopefully my earlier comment was clear about the reason for this. If Cmd+K opens the Spell Checker, but you want to reallocate it to run Jeebies, then when you press Cmd+K in the Command Edit dialog, you don't want it to open the Spell Checker, even though it is currently bound to it. Essentially, the Command Edit dialog has to catch all key strokes and not let them execute the command they are normally bound to. That behavior is incompatible with using the shortcut to execute a command while focus is in the Command Edit dialog. So, you have to put it in the background. Unless @tangledhelix has any ideas on how to avoid it.


created_at: 2025-04-27T11:59:55Z
author_association: COLLABORATOR
diff_hunk: @@ -1146,45 +1118,27 @@ def apply_changes(self) -> bool:
         CommandPaletteDialog.recreate_menus_callback()  # pylint: disable=not-callable
         return True
 
-    def do_key(self, event: tk.Event) -> None:
+    def key_press(self, event: tk.Event) -> str:
         """Handle keystroke in dialog."""
-        if event.keysym in ("BackSpace", "Delete"):
-            self.key = ""
-        elif event.keysym in CommandEditDialog.supported_keys:
-            self.key = event.keysym
-        elif (
-            event.char
-            and event.char.isascii()
-            and event.char.isprintable()
-            and not event.char.isspace()
-        ):
-            self.key = event.char.upper()
-        self.settings_to_shortcut()
-
-    def shortcut_to_settings(self) -> None:
-        """Update dialog settings based on self.shortcut."""
-        shortcut = self.shortcut_variable.get()
-        self.shift_variable.set("Shift" in shortcut)
-        self.control_variable.set("Ctrl" in shortcut)
-        self.alt_command_variable.set("Alt" in shortcut or "Cmd" in shortcut)
-        # Key needs to use original shortcut, not shortcut_variable
-        # which has been processed for display, e.g. "sterling"=>"£"
-        self.key = re.sub(r".*\+", "", self.shortcut)
-
-    def settings_to_shortcut(self) -> None:
-        """Set self.shortcut based on checkbutton settings."""
-        if not self.key:
-            self.shortcut = ""
-            return
-        shortcut = ""
-        if self.control_variable.get():
-            shortcut += "Ctrl+"
-        if self.shift_variable.get():
-            shortcut += "Shift+"
-        if self.alt_command_variable.get():
-            shortcut += "Cmd+" if is_mac() else "Alt+"
-        shortcut += self.key
-        self.shortcut = shortcut
+        keysym = event.keysym
+        if keysym in self.MODIFIER_KEYS:
path: src/guiguts/misc_dialogs.py
user: login: windymilla
body: > > But I can assign some of the option keys and get them to work -- I just have to put the edit shortcut dialog in the background, apparently.
> 
> Hopefully my earlier comment was clear about the reason for this. If Cmd+K opens the Spell Checker, but you want to reallocate it to run Jeebies, then when you press Cmd+K in the Command Edit dialog, you don't want it to open the Spell Checker, even though it is currently bound to it. Essentially, the Command Edit dialog has to catch all key strokes and not let them execute the command they are normally bound to. That behavior is incompatible with using the shortcut to execute a command while focus is in the Command Edit dialog. So, you have to put it in the background. Unless @tangledhelix has any ideas on how to avoid it.

@srjfoo - I had an idea about this. I could remove the "Apply" button from the Command Edit dialog (which dates from the previous shortcut setting method). That way, you would have to press "OK" to set the shortcut, which dismisses the dialog, and you can then use it without the shortcut keystrokes being gobbled by the dialog. Do you think that's a good idea?
created_at: 2025-04-27T13:08:28Z
author_association: COLLABORATOR
diff_hunk: @@ -1146,45 +1118,27 @@ def apply_changes(self) -> bool:
         CommandPaletteDialog.recreate_menus_callback()  # pylint: disable=not-callable
         return True
 
-    def do_key(self, event: tk.Event) -> None:
+    def key_press(self, event: tk.Event) -> str:
         """Handle keystroke in dialog."""
-        if event.keysym in ("BackSpace", "Delete"):
-            self.key = ""
-        elif event.keysym in CommandEditDialog.supported_keys:
-            self.key = event.keysym
-        elif (
-            event.char
-            and event.char.isascii()
-            and event.char.isprintable()
-            and not event.char.isspace()
-        ):
-            self.key = event.char.upper()
-        self.settings_to_shortcut()
-
-    def shortcut_to_settings(self) -> None:
-        """Update dialog settings based on self.shortcut."""
-        shortcut = self.shortcut_variable.get()
-        self.shift_variable.set("Shift" in shortcut)
-        self.control_variable.set("Ctrl" in shortcut)
-        self.alt_command_variable.set("Alt" in shortcut or "Cmd" in shortcut)
-        # Key needs to use original shortcut, not shortcut_variable
-        # which has been processed for display, e.g. "sterling"=>"£"
-        self.key = re.sub(r".*\+", "", self.shortcut)
-
-    def settings_to_shortcut(self) -> None:
-        """Set self.shortcut based on checkbutton settings."""
-        if not self.key:
-            self.shortcut = ""
-            return
-        shortcut = ""
-        if self.control_variable.get():
-            shortcut += "Ctrl+"
-        if self.shift_variable.get():
-            shortcut += "Shift+"
-        if self.alt_command_variable.get():
-            shortcut += "Cmd+" if is_mac() else "Alt+"
-        shortcut += self.key
-        self.shortcut = shortcut
+        keysym = event.keysym
+        if keysym in self.MODIFIER_KEYS:
path: src/guiguts/misc_dialogs.py
user: login: srjfoo
body: > > > But I can assign some of the option keys and get them to work -- I just have to put the edit shortcut dialog in the background, apparently.
> > 
> > 
> > Hopefully my earlier comment was clear about the reason for this. If Cmd+K opens the Spell Checker, but you want to reallocate it to run Jeebies, then when you press Cmd+K in the Command Edit dialog, you don't want it to open the Spell Checker, even though it is currently bound to it. Essentially, the Command Edit dialog has to catch all key strokes and not let them execute the command they are normally bound to. That behavior is incompatible with using the shortcut to execute a command while focus is in the Command Edit dialog. So, you have to put it in the background. Unless @tangledhelix has any ideas on how to avoid it.
> 
> @srjfoo - I had an idea about this. I could remove the "Apply" button from the Command Edit dialog (which dates from the previous shortcut setting method). That way, you would have to press "OK" to set the shortcut, which dismisses the dialog, and you can then use it without the shortcut keystrokes being gobbled by the dialog. Do you think that's a good idea?

In actual production use, maybe so. For testing purposes, probably not. That would force me to re-open the "Edit Shortcut" dialog for every test assignment I make, which would be annoying. What about a "Test" button in the dialog? (Either as a temporary expedient, or permanent, if you think it's a useful idea.) But it's not really necessary, I can get used to putting the dialog in the background -- I'm not sure why I thought it worked before, because your explanation makes perfect sense, and I have no idea why that didn't occur to me.
created_at: 2025-04-27T17:06:42Z
author_association: MEMBER
diff_hunk: @@ -129,8 +129,12 @@ def show_dialog(
         if dlg := cls.get_dialog():
             dlg.deiconify()
             dlg.reset()
+            # On Linux, Window Managers stop dialogs being brought to the front by deiconify,
+            # or anything else. Destroying the dialog and recreating it gets round that.
+            if is_x11():
+                dlg.destroy()
path: src/guiguts/widgets.py
user: login: tangledhelix
body: I don't think this is a necessary step. This patch is working for me on Linux to bring the palette back to focus (and in the case of that specific dialog, it focuses the text entry too). For other things like jeebies, spellcheck, etc, it also seems to raise them as expected.

```diff
diff --git a/src/guiguts/widgets.py b/src/guiguts/widgets.py
index 11eb566..3c3ba6c 100644
--- a/src/guiguts/widgets.py
+++ b/src/guiguts/widgets.py
@@ -128,6 +128,10 @@ class ToplevelDialog(tk.Toplevel):
         # Can we just deiconify dialog & reset it?
         if dlg := cls.get_dialog():
             dlg.deiconify()
+            if is_x11():
+                grab_focus(dlg)
+                if dlg_name == "CommandPaletteDialog":
+                    dlg.entry.focus()
             dlg.reset()
         # Or do we need to create it?
         else:
```
created_at: 2025-04-29T04:23:12Z
author_association: MEMBER
diff_hunk: @@ -129,8 +129,12 @@ def show_dialog(
         if dlg := cls.get_dialog():
             dlg.deiconify()
             dlg.reset()
+            # On Linux, Window Managers stop dialogs being brought to the front by deiconify,
+            # or anything else. Destroying the dialog and recreating it gets round that.
+            if is_x11():
+                dlg.destroy()
path: src/guiguts/widgets.py
user: login: windymilla
body: On WSL, that does put the focus in the Command Palette (in the text entry), but it doesn't raise it to the top of the window manager stack. Does it raise it on your Linux, @tangledhelix?

For me if the Command Palette is hidden behind the main window, you still have the same issue that you originally described - it seems like the shortcut or menu button haven't worked. It looks like it's the kind of thing that will be dependent on the Linux window manager being used.

If this is useful on some window managers, we should keep it. However, we'd need to structure it slightly differently. The TopLevelDialog class is not permitted to know/use the names of specific dialogs. 

Probably we should add a `grab_focus` method, which in TopLevelDialog would do:
```python
if is_x11():
    grab_focus(self)
```
Then in CommandPaletteDialog we'd override `grab_focus` to first call the base class method, then (on all platforms):
```python
    self.entry.focus()
```
Then we would add `dlg.grab_focus()` to TopLevelDialog's `show_dialog` method instead of the code you have tested out there.

Some other dialogs might benefit from similarly overriding the new `grab_focus` method to force focus to a specific widget within the dialog.

I'm happy to do that work, if you confirm that your code is solving the "raise to the top" issue on your Linux window manager, even if it doesn't solve it for WSL.
created_at: 2025-04-29T12:42:48Z
author_association: COLLABORATOR

PR comments:
user: login: windymilla
created_at: 2025-04-26T19:11:20Z
body: Testing notes: anything may have broken, so testing should include
1. All printable ASCII characters should be able to be used as the base key, though not every one has to be tested. Also a few others, such as `¬£§±` and backtick (which I can't make appear here) which are displayed on a Windows UK keyboard or an Apple UK keyboard. "Unknown" keys, i.e. ones I haven't catered or explicitly in the code could/should still work but will appear in the display with the Tk name, rather than the character, e.g. if you can type `Ctrl+¶` on your keyboard, it will probably appear as `Ctrl+paragraph`. This should mean that users with LOTE keyboards can hopefully still use the feature. 
2.  Re-using a shortcut you assigned previously
3. Re-using a shortcut that was assigned to an existing feature by default, e.g. Cmd+O for Open file.
4. Attempting to set up a shortcut without using Ctrl, Cmd, Option or Alt for a key that has a single character name, e.g. "x" as opposed to "Home"
5. F function keys should still work.
6. It's possible you may be able to fool the detection of the modifier keys if you really try, like pressing left Ctrl and right Ctrl, then releasing left Ctrl, then switching focus to another app, then releasing right Ctrl, or other stuff like that, but I hope for "normal" use it will be reliable.
author_association: COLLABORATOR
user: login: tangledhelix
created_at: 2025-04-27T06:58:09Z
body: I immediately get this traceback on Linux:

```
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/home/ubuntu/guiguts-py/src/guiguts/application.py", line 1315, in main
    Guiguts().run()
    ^^^^^^^^^
  File "/home/ubuntu/guiguts-py/src/guiguts/application.py", line 146, in __init__
    self.init_menus()
  File "/home/ubuntu/guiguts-py/src/guiguts/application.py", line 577, in init_menus
    self.recreate_menus()
  File "/home/ubuntu/guiguts-py/src/guiguts/application.py", line 1025, in recreate_menus
    self.add_entries(top_level)
  File "/home/ubuntu/guiguts-py/src/guiguts/application.py", line 1048, in add_entries
    self.add_entries(entry)  # Add its entries
    ^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/guiguts-py/src/guiguts/application.py", line 1050, in add_entries
    tk_menu.add_button(  # type:ignore[attr-defined]
  File "/home/ubuntu/guiguts-py/src/guiguts/mainwindow.py", line 96, in add_button
    Menu.bind_button(callback, accel, bind_all)
  File "/home/ubuntu/guiguts-py/src/guiguts/mainwindow.py", line 120, in bind_button
    maintext().key_bind(key_event, lambda _: callback(), bind_all=bind_all)
  File "/home/ubuntu/guiguts-py/src/guiguts/maintext.py", line 1405, in key_bind
    self.bind_event(lk, handler, bind_all=bind_all, bind_peer=True)
  File "/home/ubuntu/guiguts-py/src/guiguts/maintext.py", line 1184, in bind_event
    self.bind(event_string, break_func, add)
  File "/usr/lib/python3.12/tkinter/__init__.py", line 1450, in bind
    return self._bind(('bind', self._w), sequence, func, add)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/tkinter/__init__.py", line 1404, in _bind
    self.tk.call(what + (sequence, cmd))
_tkinter.TclError: bad event type or keysym "!"
```

It doesn't like the set of keybinds defined in the `shortcuts` list in `init_bookmark_menu`. I had to comment out all five of them before it would even start.
author_association: MEMBER
user: login: windymilla
created_at: 2025-04-27T07:02:42Z
body: That's an oversight. Since the latest version, that should be using ctrl-shift-exclamation and the code converts the exclamation to ! For display purposes. 
author_association: COLLABORATOR
user: login: windymilla
created_at: 2025-04-27T11:40:38Z
body: > It doesn't like the set of keybinds defined in the `shortcuts` list in `init_bookmark_menu`. I had to comment out all five of them before it would even start.

This should be fixed now.
author_association: COLLABORATOR
user: login: windymilla
created_at: 2025-04-27T13:48:13Z
body: Latest commit clears modifier flags if dialog loses focus, so that using Alt-tab doesn't leave behind an "Alt is pressed" flag in the dialog.

Also, should now get an error message when trying to bind a new sequence if it is not supported. Code does a test bind to a dummy widget and traps the exception.

author_association: COLLABORATOR
user: login: windymilla
created_at: 2025-04-28T15:43:52Z
body: Re-requesting from @tangledhelix due to Linux-specific report of dialog not popping to the front.
author_association: COLLABORATOR
user: login: windymilla
created_at: 2025-04-29T13:00:52Z
body: @tangledhelix - Please can you confirm if the latest commit (which I think is broadly equivalent to your code) solves the "raising" issue on your Linux. Thanks.
author_association: COLLABORATOR
user: login: windymilla
created_at: 2025-04-29T18:59:14Z
body: Fixes #970 

author_association: COLLABORATOR
user: login: tangledhelix
created_at: 2025-04-30T04:51:31Z
body: > @tangledhelix - Please can you confirm if the latest commit (which I think is broadly equivalent to your code) solves the "raising" issue on your Linux. Thanks.

Yes, it does. I didn't try every possible tool, but it works for the Palette, Bookloupe, Jeebies, and Spelling, at least.

I don't know if this is expected, but raising the latter 3 mentioned above caused a re-run to happen. That may be normal, but I wasn't expecting it to happen since I didn't tell it to re-run. I would think the user wouldn't want that, because if they had cleared some items out of the list already, then clicked into the window (thus de-focusing the tool) and going back, they may well not want everything to come back that they had already cleared.
author_association: MEMBER
user: login: windymilla
created_at: 2025-04-30T06:30:20Z
body: > > @tangledhelix - Please can you confirm if the latest commit (which I think is broadly equivalent to your code) solves the "raising" issue on your Linux. Thanks.
> 
> Yes, it does. I didn't try every possible tool, but it works for the Palette, Bookloupe, Jeebies, and Spelling, at least.
> 
> I don't know if this is expected, but raising the latter 3 mentioned above caused a re-run to happen. That may be normal, but I wasn't expecting it to happen since I didn't tell it to re-run. I would think the user wouldn't want that, because if they had cleared some items out of the list already, then clicked into the window (thus de-focusing the tool) and going back, they may well not want everything to come back that they had already cleared.

Just refocusing by clicking the dialog or Cmd tab doesn't rerun, does it? But if you choose the menu option or use a shortcut it should rerun. The menu option means run the tool, not focus/raise the dialog. Focusing and raising is a side effect of running the tool. 
author_association: COLLABORATOR
user: login: tangledhelix
created_at: 2025-05-01T03:11:40Z
body: > Just refocusing by clicking the dialog or Cmd tab doesn't rerun, does it?

That's correct.

Edited to clarify: correct, they do not re-run.
author_association: MEMBER