import tkinter as tk from tkinter import ttk, messagebox import keyboard import platform class HotkeyManager: """Class to handle hotkey operations.""" def __init__(self, app): self.app = app self.hotkeys = [] # Track registered hotkeys self.is_mac = platform.system() == 'Darwin' self.setup_hotkeys() def setup_hotkeys(self): """Set up hotkeys based on settings.""" # First, clear all existing hotkeys self.clear_hotkeys() settings = self.app.load_settings() # Register the hotkeys and track them self.register_hotkeys(settings["hotkeys"]) def register_hotkeys(self, hotkey_settings): """Register hotkeys from settings and track them.""" try: self.hotkeys.append(keyboard.add_hotkey( self.format_shortcut(hotkey_settings["record_start_stop"]), lambda: self.hotkey_record_trigger() )) self.hotkeys.append(keyboard.add_hotkey( self.format_shortcut(hotkey_settings["stop_recording"]), lambda: self.hotkey_stop_trigger() )) self.hotkeys.append(keyboard.add_hotkey( self.format_shortcut(hotkey_settings["play_last_audio"]), lambda: self.hotkey_play_last_audio_trigger() )) return True except Exception as e: print(f"Error registering hotkeys: {e}") return False def clear_hotkeys(self): """Clear all registered hotkeys.""" try: # Clear tracked hotkeys for hotkey in self.hotkeys: try: keyboard.remove_hotkey(hotkey) except: pass self.hotkeys.clear() # Also attempt to use the more aggressive approach try: keyboard.unhook_all() except AttributeError: pass # Ignore if the method isn't supported # Try to reset internal keyboard state try: keyboard._recording = False keyboard._pressed_events.clear() keyboard._physically_pressed_keys.clear() keyboard._logically_pressed_keys.clear() except Exception as e: print(f"Warning: Error while resetting keyboard state: {e}") except Exception as e: print(f"Error clearing hotkeys: {e}") def force_hotkey_refresh(self, callback=None): """Force a complete refresh of all hotkeys.""" print("Forcing hotkey refresh") try: # Clear all existing keyboard hooks self.clear_hotkeys() # Get current settings settings = self.app.load_settings() # Re-register hotkeys success = self.register_hotkeys(settings["hotkeys"]) if success and self.hotkeys: print(f"Hotkey refresh completed successfully with {len(self.hotkeys)} hotkeys") if callback: callback(True) return True else: print("Failed to register hotkeys") if callback: callback(False) messagebox.showerror("Hotkey Error", "Failed to re-register hotkeys.") return False except Exception as e: print(f"Error during hotkey refresh: {e}") if callback: callback(False) return False def verify_hotkeys(self): """Verify that hotkeys are working.""" try: # Simple check if hotkeys are registered return len(self.hotkeys) > 0 except: return False def format_shortcut(self, key_combo): """Format key combination consistently.""" if isinstance(key_combo, (list, set)): # Convert to list if it's a set if isinstance(key_combo, set): key_combo = list(key_combo) # Filter out empty values first filtered_combo = list(filter(None, key_combo)) # Define modifier order modifier_order = ['ctrl', 'alt', 'shift', 'win', 'command'] # Split into modifiers and regular keys modifiers = [k for k in filtered_combo if k.lower() in modifier_order] regular_keys = [k for k in filtered_combo if k.lower() not in modifier_order] # Sort modifiers according to preferred order sorted_modifiers = sorted(modifiers, key=lambda x: modifier_order.index(x.lower()) if x.lower() in modifier_order else 999) # For regular keys, we don't want to sort them as they might be multiple characters # We only need one non-modifier key anyway if len(regular_keys) > 1: # Keep only the first non-modifier key to avoid confusion print(f"Warning: Multiple non-modifier keys detected: {regular_keys}. Using only: {regular_keys[0]}") regular_keys = [regular_keys[0]] # Combine sorted modifiers and regular keys combined_keys = sorted_modifiers + regular_keys # Make sure we have at least one key if not combined_keys: return "" # Join with plus signs return "+".join(combined_keys) elif isinstance(key_combo, str): # If it's already a string, split it and re-format it return self.format_shortcut(key_combo.split('+')) else: return "" def hotkey_play_last_audio_trigger(self): """Trigger playing the last audio.""" if hasattr(self.app, 'last_audio_file'): self.app.play_last_audio() else: self.app.play_sound('assets/no-last-audio.wav') def hotkey_stop_trigger(self): """Trigger stopping the recording.""" self.app.play_sound('assets/wrong-short.wav') if self.app.recording: self.app.stop_recording(auto_play=False) self.app.recording = False def hotkey_record_trigger(self): """Trigger recording or stop recording and submit.""" if self.app.recording: self.app.play_sound('assets/pop.wav') self.app.submit_text() else: if not self.app.recording: self.app.start_recording(play_confirm_sound=True) else: self.app.stop_recording(auto_play=True) @staticmethod def hotkey_settings_dialog(app): """Show the hotkey settings dialog with interactive key capture.""" settings = app.load_settings() hotkey_window = tk.Toplevel(app) hotkey_window.title("Hotkey Settings") hotkey_window.grab_set() # Grab the focus on this toplevel window # Temporarily suspend global hotkeys old_hotkeys = [] if hasattr(app, 'hotkey_manager'): # Store current hotkeys old_hotkeys = app.hotkey_manager.hotkeys.copy() # Clear them while dialog is open app.hotkey_manager.clear_hotkeys() # Set size and center the window window_width = 500 window_height = 400 position_x = app.winfo_x() + (app.winfo_width() - window_width) // 2 position_y = app.winfo_y() + (app.winfo_height() - window_height) // 2 hotkey_window.geometry(f"{window_width}x{window_height}+{position_x}+{position_y}") main_frame = ttk.Frame(hotkey_window, padding="20") main_frame.pack(fill=tk.BOTH, expand=True) # Add title title_label = ttk.Label( main_frame, text="Keyboard Shortcuts", font=("Arial", 12, "bold") ) title_label.pack(pady=(0, 15)) # Create frame for shortcuts shortcuts_frame = ttk.Frame(main_frame) shortcuts_frame.pack(fill=tk.BOTH, expand=True) hotkey_manager = app.hotkey_manager if hasattr(app, 'hotkey_manager') else HotkeyManager(app) # Dictionary to store labels for each shortcut for easy updating shortcut_labels = {} # Function to handle shortcut editing def start_shortcut_edit(shortcut_name, button, label): # Add check for valid button if not button or not button.winfo_exists(): print("Warning: Button no longer exists") return button.config(text="Press new shortcut...") # Track pressed keys and modifiers pressed_keys = set() currently_pressed = set() # Track keys that are currently held down last_state = 0 # Track the last event state def on_key_press(event): nonlocal last_state last_state = event.state # Convert key to lowercase key = event.keysym.lower() # Debug print print(f"Key press - key: {key}") print(f"State bits: {format(event.state, '016b')}") print(f"State value: {event.state}") print(f"Currently pressed keys before: {currently_pressed}") # Map left/right modifier variants to their base form modifier_map = { 'control_l': 'ctrl', 'control_r': 'ctrl', 'alt_l': 'alt', 'alt_r': 'alt', 'shift_l': 'shift', 'shift_r': 'shift', 'super_l': 'win', 'super_r': 'win', 'win_l': 'win', 'win_r': 'win' } # Map for shift+number keys to their actual number shift_number_map = { # Standard US layout shift+number keys 'exclam': '1', # Shift+1 'at': '2', # Shift+2 'numbersign': '3', # Shift+3 'dollar': '4', # Shift+4 'percent': '5', # Shift+5 'asciicircum': '6', # Shift+6 'ampersand': '7', # Shift+7 'asterisk': '8', # Shift+8 'parenleft': '9', # Shift+9 'parenright': '0', # Shift+0 # Additional keys for different keyboard layouts 'quotedbl': '2', # Shift+2 on some layouts 'sterling': '3', # Shift+3 on UK layout 'eacute': '2', # French keyboards 'quoteleft': '`', # Backtick 'asciitilde': '`', # Shift+` 'equal': '=', # Equal sign 'plus': '=', # Shift+= 'minus': '-', # Minus sign 'underscore': '-', # Shift+- 'bracketleft': '[', # Left bracket 'braceleft': '[', # Shift+[ 'bracketright': ']', # Right bracket 'braceright': ']', # Shift+] 'backslash': '\\', # Backslash 'bar': '\\', # Shift+\ 'semicolon': ';', # Semicolon 'colon': ';', # Shift+; 'apostrophe': "'", # Apostrophe 'comma': ',', # Comma 'less': ',', # Shift+, 'period': '.', # Period 'greater': '.', # Shift+. 'slash': '/', # Slash 'question': '/' # Shift+/ } # Additional direct mappings for numeric keys - handle different representations numeric_keys_map = { 'kp_0': '0', 'kp_1': '1', 'kp_2': '2', 'kp_3': '3', 'kp_4': '4', 'kp_5': '5', 'kp_6': '6', 'kp_7': '7', 'kp_8': '8', 'kp_9': '9', 'zero': '0', 'one': '1', 'two': '2', 'three': '3', 'four': '4', 'five': '5', 'six': '6', 'seven': '7', 'eight': '8', 'nine': '9', } # Check if this is a shift+number key if key in shift_number_map: key = shift_number_map[key] print(f"Mapped shift+number key to: {key}") # Check if it's a numeric key with a different representation elif key in numeric_keys_map: key = numeric_keys_map[key] print(f"Mapped numeric key to: {key}") # Special handling for numpad keys which might come as "kp_1", "kp_2" elif key.startswith('kp_') and len(key) > 3: mapped_key = key[3:] # Strip the 'kp_' prefix print(f"Mapped keypad key from {key} to {mapped_key}") key = mapped_key # Handle direct number key presses - these might be reported with character codes if event.char and event.char.isdigit() and not key.isdigit(): print(f"Detected digit character: {event.char}") key = event.char # Add to currently pressed keys if key in modifier_map: mod_key = modifier_map[key] currently_pressed.add(mod_key) else: # Only add non-modifier keys if they're actual keys (not just state changes) if len(key) == 1 or key in ('left', 'right', 'up', 'down', 'space', 'tab', 'return', 'backspace', 'delete', 'escape', 'home', 'end', 'pageup', 'pagedown', 'insert', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12'): currently_pressed.add(key) elif key: # If it's any other non-empty key, add it anyway print(f"Adding unrecognized key: {key}") currently_pressed.add(key) # Store the character directly if it exists and isn't already captured if event.char and event.char not in currently_pressed and event.char not in ('', ' '): currently_pressed.add(event.char) # Update pressed_keys with all current keys pressed_keys.clear() pressed_keys.update(currently_pressed) # Add modifiers based on state if event.state & 0x4: pressed_keys.add('ctrl') if event.state & 0x1: pressed_keys.add('shift') if event.state & 0x20000: pressed_keys.add('alt') if event.state & 0x40000 or 'win' in currently_pressed: pressed_keys.add('win') print(f"Currently pressed keys after: {pressed_keys}") # Update button text to show current combination current_combo = "+".join(sorted(pressed_keys)) button.config(text=f"Press: {current_combo}") return "break" def on_key_release(event): nonlocal currently_pressed key = event.keysym.lower() print(f"Key release - key: {key}") print(f"Key event char: {repr(event.char)}") print(f"Currently pressed before release: {currently_pressed}") # Map for shift+number keys to their actual number (same as in on_key_press) shift_number_map = { # Standard US layout shift+number keys 'exclam': '1', # Shift+1 'at': '2', # Shift+2 'numbersign': '3', # Shift+3 'dollar': '4', # Shift+4 'percent': '5', # Shift+5 'asciicircum': '6', # Shift+6 'ampersand': '7', # Shift+7 'asterisk': '8', # Shift+8 'parenleft': '9', # Shift+9 'parenright': '0', # Shift+0 # Additional keys for different keyboard layouts 'quotedbl': '2', # Shift+2 on some layouts 'sterling': '3', # Shift+3 on UK layout 'eacute': '2', # French keyboards 'quoteleft': '`', # Backtick 'asciitilde': '`', # Shift+` 'equal': '=', # Equal sign 'plus': '=', # Shift+= 'minus': '-', # Minus sign 'underscore': '-', # Shift+- 'bracketleft': '[', # Left bracket 'braceleft': '[', # Shift+[ 'bracketright': ']', # Right bracket 'braceright': ']', # Shift+] 'backslash': '\\', # Backslash 'bar': '\\', # Shift+\ 'semicolon': ';', # Semicolon 'colon': ';', # Shift+; 'apostrophe': "'", # Apostrophe 'comma': ',', # Comma 'less': ',', # Shift+, 'period': '.', # Period 'greater': '.', # Shift+. 'slash': '/', # Slash 'question': '/' # Shift+/ } # Additional direct mappings for numeric keys - handle different representations numeric_keys_map = { 'kp_0': '0', 'kp_1': '1', 'kp_2': '2', 'kp_3': '3', 'kp_4': '4', 'kp_5': '5', 'kp_6': '6', 'kp_7': '7', 'kp_8': '8', 'kp_9': '9', 'zero': '0', 'one': '1', 'two': '2', 'three': '3', 'four': '4', 'five': '5', 'six': '6', 'seven': '7', 'eight': '8', 'nine': '9', } # Check if this is a shift+number key if key in shift_number_map: key = shift_number_map[key] print(f"Mapped shift+number key release to: {key}") # Check if it's a numeric key with a different representation elif key in numeric_keys_map: key = numeric_keys_map[key] print(f"Mapped numeric key release to: {key}") # Special handling for numpad keys elif key.startswith('kp_') and len(key) > 3: mapped_key = key[3:] # Strip the 'kp_' prefix print(f"Mapped keypad key release from {key} to {mapped_key}") key = mapped_key # Handle direct number key presses - these might be reported with character codes if event.char and event.char.isdigit() and not key.isdigit(): print(f"Detected digit character release: {event.char}") key = event.char # Remove released key from currently pressed set if key in currently_pressed: currently_pressed.remove(key) else: # Try to find the key in currently_pressed by removing case, digit comparison, etc. # This helps catch edge cases where the key representation changes between press and release for pressed_key in list(currently_pressed): # Check if it's the same key but with different case if pressed_key.lower() == key.lower(): print(f"Found case-insensitive match: {pressed_key} for released key: {key}") currently_pressed.remove(pressed_key) break # Check if both are digits but with different representations elif (pressed_key.isdigit() and key.isdigit()) or \ (pressed_key == '0' and key == 'parenright') or \ (pressed_key in '0123456789' and key in numeric_keys_map.values()): print(f"Found digit key match: {pressed_key} for released key: {key}") currently_pressed.remove(pressed_key) break # If we're releasing a character key, remove that character too if event.char and event.char in currently_pressed: currently_pressed.remove(event.char) # Handle modifier key releases modifier_map = { 'control_l': 'ctrl', 'control_r': 'ctrl', 'alt_l': 'alt', 'alt_r': 'alt', 'shift_l': 'shift', 'shift_r': 'shift', 'super_l': 'win', 'super_r': 'win', 'win_l': 'win', 'win_r': 'win' } if key in modifier_map: mod_key = modifier_map[key] if mod_key in currently_pressed: currently_pressed.remove(mod_key) print(f"Currently pressed after release: {currently_pressed}") # Process hotkey when all keys are released # Or when we have a complete valid hotkey (modifiers + key) has_complete_hotkey = ( len(pressed_keys) >= 2 and # At least 2 keys any(mod in pressed_keys for mod in ('ctrl', 'alt', 'shift', 'win', 'command')) and # Has modifier any(k not in ('ctrl', 'alt', 'shift', 'win', 'command') for k in pressed_keys) # Has non-modifier ) # Either all keys are released, or we have a complete valid hotkey on key release if (not currently_pressed) or (has_complete_hotkey and key not in modifier_map): try: print(f"Processing hotkey: {pressed_keys}") # Check for empty pressed_keys if not pressed_keys: print("Warning: No keys in pressed_keys, aborting shortcut update") button.config(text="Edit") return # Create the shortcut string with consistent ordering new_shortcut = hotkey_manager.format_shortcut(pressed_keys) print(f"Formatted shortcut: '{new_shortcut}'") # Check if the formatting succeeded if not new_shortcut: print("Warning: format_shortcut returned empty string") messagebox.showerror("Error", "Failed to format shortcut keys") button.config(text="Edit") return # Check if there's at least one modifier has_modifier = any(mod in pressed_keys for mod in ('ctrl', 'alt', 'shift', 'win', 'command')) # Validate the shortcut if not has_modifier: messagebox.showerror("Error", "Please include at least one modifier key (Ctrl, Alt, Shift, or Win)") button.config(text="Edit") return # Check if there's a non-modifier key has_key = any(k not in ('ctrl', 'alt', 'shift', 'win', 'command') for k in pressed_keys) if not has_key: # Keep waiting for a full key combo return # Convert to list format for settings # Get a sorted list of modifiers first modifier_order = ['ctrl', 'alt', 'shift', 'win', 'command'] modifiers = sorted([k for k in pressed_keys if k in modifier_order], key=lambda x: modifier_order.index(x)) # Get the first non-modifier key main_key = next((k for k in pressed_keys if k not in modifier_order), "") # Create the 3-element format: [modifier1, modifier2, main_key] shortcut_list = [ modifiers[0] if len(modifiers) > 0 else "", modifiers[1] if len(modifiers) > 1 else "", main_key ] print(f"Shortcut list: {shortcut_list}") # Update settings settings = app.load_settings() settings["hotkeys"][shortcut_name] = shortcut_list app.save_settings_to_JSON(settings) # Update the label immediately with the newly formatted shortcut label.config(text=new_shortcut) # Ensure the update takes effect by forcing a UI update label.update_idletasks() # Update button text button.config(text="Edit") button.update_idletasks() # Force a focus change to ensure the key events are processed hotkey_window.focus_set() # Show a confirmation to the user print(f"Shortcut updated to: '{new_shortcut}'") except Exception as e: print(f"Error saving shortcut: {e}") import traceback traceback.print_exc() messagebox.showerror("Error", f"Failed to update shortcut: {e}") button.config(text="Edit") finally: # Clear the sets pressed_keys.clear() currently_pressed.clear() # Remove key bindings hotkey_window.unbind('') hotkey_window.unbind('') # Remove any existing bindings first hotkey_window.unbind('') hotkey_window.unbind('') # Bind both key press and release events hotkey_window.bind('', on_key_press) hotkey_window.bind('', on_key_release) # Add each shortcut with its edit button row = 0 for name, key_combo in settings["hotkeys"].items(): # Format readable name display_name = name.replace('_', ' ').title() + ":" # Format the current shortcut for display current_shortcut = hotkey_manager.format_shortcut(key_combo) # Create frame for this shortcut frame = ttk.Frame(shortcuts_frame) frame.pack(fill=tk.X, pady=5) # Add shortcut name name_label = ttk.Label(frame, text=display_name, width=20, anchor="w") name_label.pack(side=tk.LEFT, padx=(0, 10)) # Add current shortcut shortcut_label = ttk.Label(frame, text=current_shortcut, width=15, anchor="w") shortcut_label.pack(side=tk.LEFT, padx=(0, 10)) # Store label reference for easy access shortcut_labels[name] = shortcut_label # Add edit button edit_button = ttk.Button( frame, text="Edit", width=10 ) edit_button.pack(side=tk.RIGHT) # Configure button command after creation to avoid stale references edit_button.configure(command=lambda n=name, b=edit_button, l=shortcut_label: start_shortcut_edit(n, b, l)) row += 1 # Create a frame for the buttons button_frame = ttk.Frame(main_frame) button_frame.pack(pady=20) # Add refresh button refresh_button = ttk.Button( button_frame, text="Save & Close", width=15, command=lambda: close_and_save() ) refresh_button.pack(side=tk.LEFT, padx=5) # Add reset to defaults button reset_button = ttk.Button( button_frame, text="Reset to Defaults", width=15, command=lambda: HotkeyManager.reset_shortcuts_to_default(app, hotkey_window) ) reset_button.pack(side=tk.LEFT, padx=5) # Add note about Windows lock note_text = ("Note: If shortcuts stop working after unlocking Windows,\n" "use this dialog to refresh them.") ttk.Label( main_frame, text=note_text, justify=tk.CENTER, font=("Arial", 9), foreground="#666666" ).pack(pady=10) # Function to properly close the dialog and restore hotkeys def close_and_save(): # Re-register global hotkeys before closing if hasattr(app, 'hotkey_manager'): app.hotkey_manager.force_hotkey_refresh() messagebox.showinfo("Settings Updated", "Your hotkey settings have been saved successfully.") hotkey_window.destroy() # Close button close_button = ttk.Button( main_frame, text="Cancel", command=lambda: cancel_and_close() ) close_button.pack(pady=(5, 0)) # Function to cancel without saving def cancel_and_close(): # Re-register the original hotkeys if hasattr(app, 'hotkey_manager'): # Force reload from settings app.hotkey_manager.setup_hotkeys() hotkey_window.destroy() # Make sure hotkeys are re-registered when window is closed hotkey_window.protocol("WM_DELETE_WINDOW", cancel_and_close) @staticmethod def reset_shortcuts_to_default(app, parent_window=None): """Reset all keyboard shortcuts to their default values.""" if parent_window is None: parent_window = app # Create custom confirmation dialog confirm = messagebox.askyesno( "Reset Shortcuts", "Are you sure you want to reset all keyboard shortcuts to their default values?", parent=parent_window ) if confirm: try: # Default shortcuts is_mac = platform.system() == 'Darwin' default_shortcuts = { "record_start_stop": ["ctrl", "shift", "0"], "stop_recording": ["ctrl", "shift", "9"], "play_last_audio": ["ctrl", "shift", "8"] } # Update settings settings = app.load_settings() settings["hotkeys"] = default_shortcuts app.save_settings_to_JSON(settings) # Refresh hotkeys def on_refresh_complete(success): if success: messagebox.showinfo( "Success", "Shortcuts have been reset to defaults", parent=parent_window ) # If this is a settings dialog, close and reopen it to refresh the UI if parent_window != app and hasattr(parent_window, 'destroy'): parent_window.destroy() app.after(100, lambda: HotkeyManager.hotkey_settings_dialog(app)) else: messagebox.showerror( "Error", "Failed to register default shortcuts.", parent=parent_window ) app.hotkey_manager.force_hotkey_refresh(callback=on_refresh_complete) except Exception as e: messagebox.showerror( "Error", f"Failed to reset shortcuts: {e}", parent=parent_window ) @staticmethod def show_hotkey_instructions(parent): """Show instructions for using hotkeys.""" instruction_window = tk.Toplevel(parent) instruction_window.title("Hotkey Instructions") instruction_window.geometry("600x500") # Width x Height settings = parent.load_settings() # Format hotkeys consistently for display hotkey_manager = parent.hotkey_manager if hasattr(parent, 'hotkey_manager') else HotkeyManager(parent) record_shortcut = hotkey_manager.format_shortcut(settings["hotkeys"]["record_start_stop"]) play_shortcut = hotkey_manager.format_shortcut(settings["hotkeys"]["play_last_audio"]) stop_shortcut = hotkey_manager.format_shortcut(settings["hotkeys"]["stop_recording"]) instructions = f"""Available Hotkeys: 1. {record_shortcut} - Start/Stop Recording This hotkey toggles recording from your selected input device. 2. {play_shortcut} - Play Last Audio Play the most recently generated audio. 3. {stop_shortcut} - Stop Recording Immediately stops any active recording. These hotkeys work globally across your system, even when the app is minimized. You can customize these hotkeys in Settings → Hotkey Settings. If hotkeys stop working after your computer wakes from sleep or after unlocking your screen, use Settings → Hotkey Settings → Refresh Hotkeys to restore functionality. You can also access tone presets from Settings → Manage Tone Presets to modify how your text is spoken. """ main_frame = ttk.Frame(instruction_window, padding="20") main_frame.pack(fill=tk.BOTH, expand=True) # Add scrolling text text_frame = ttk.Frame(main_frame) text_frame.pack(fill=tk.BOTH, expand=True, pady=10) scrollbar = ttk.Scrollbar(text_frame) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) text_widget = tk.Text(text_frame, wrap=tk.WORD, yscrollcommand=scrollbar.set) text_widget.insert(tk.END, instructions) text_widget.config(state=tk.DISABLED) # Make read-only text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar.config(command=text_widget.yview) # Add refresh button refresh_btn = ttk.Button(main_frame, text="Refresh Hotkeys", command=lambda: parent.hotkey_manager.force_hotkey_refresh( callback=lambda success: messagebox.showinfo("Hotkey Refresh", "Hotkeys refreshed successfully" if success else "Failed to refresh hotkeys") )) refresh_btn.pack(pady=(5, 10)) # Add a close button ttk.Button(main_frame, text="Close", command=instruction_window.destroy).pack(pady=5)