903 lines
40 KiB
Python
903 lines
40 KiB
Python
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()
|
|
))
|
|
|
|
# Add cancel operation hotkey
|
|
self.hotkeys.append(keyboard.add_hotkey(
|
|
self.format_shortcut(hotkey_settings["cancel_operation"]),
|
|
lambda: self.hotkey_cancel_operation_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')
|
|
# When stopping via hotkey, we want to correctly update UI for playback
|
|
# We'll use the same methods as the button click handlers would use
|
|
if hasattr(self.app, 'handle_submit_button_click'):
|
|
# This will properly handle stopping recording and playing audio
|
|
# with appropriate UI updates
|
|
self.app.handle_submit_button_click(via_hotkey=True)
|
|
else:
|
|
# Fall back to the old behavior if the handler isn't available
|
|
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)
|
|
|
|
def hotkey_cancel_operation_trigger(self):
|
|
"""Cancel current operation (recording or playback)."""
|
|
print("Cancel operation hotkey triggered")
|
|
|
|
# Play feedback sound first
|
|
self.app.play_sound('assets/pop.wav')
|
|
|
|
# For recording cancellation
|
|
if self.app.recording:
|
|
print("Canceling recording operation")
|
|
# Schedule the stop_recording call on the main thread to avoid threading issues
|
|
self.app.after(100, lambda: self._safe_cancel_recording())
|
|
return
|
|
|
|
# For playback cancellation
|
|
if hasattr(self.app, 'is_playing') and self.app.is_playing:
|
|
print("Canceling playback operation")
|
|
# Schedule the stop_playback call on the main thread to avoid threading issues
|
|
self.app.after(100, lambda: self._safe_cancel_playback())
|
|
return
|
|
|
|
print("No active operation to cancel")
|
|
|
|
def _safe_cancel_recording(self):
|
|
"""Safely cancel recording on the main thread."""
|
|
try:
|
|
if self.app.recording:
|
|
self.app.stop_recording(cancel_save=True)
|
|
self.app.recording = False
|
|
print("Recording cancelled successfully")
|
|
except Exception as e:
|
|
print(f"Error canceling recording: {e}")
|
|
|
|
def _safe_cancel_playback(self):
|
|
"""Safely cancel playback on the main thread."""
|
|
try:
|
|
if hasattr(self.app, 'stop_playback') and callable(self.app.stop_playback):
|
|
# This will also update the buttons through the stop_playback method
|
|
self.app.stop_playback()
|
|
print("Playback cancelled successfully")
|
|
|
|
# Ensure buttons are reset (belt and suspenders approach)
|
|
if hasattr(self.app, 'update_buttons_for_playback') and callable(self.app.update_buttons_for_playback):
|
|
self.app.update_buttons_for_playback(False)
|
|
except Exception as e:
|
|
print(f"Error canceling playback: {e}")
|
|
|
|
@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('<KeyPress>')
|
|
hotkey_window.unbind('<KeyRelease>')
|
|
|
|
# Remove any existing bindings first
|
|
hotkey_window.unbind('<KeyPress>')
|
|
hotkey_window.unbind('<KeyRelease>')
|
|
|
|
# Bind both key press and release events
|
|
hotkey_window.bind('<KeyPress>', on_key_press)
|
|
hotkey_window.bind('<KeyRelease>', 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"],
|
|
"cancel_operation": ["ctrl", "shift", "1"]
|
|
}
|
|
|
|
# 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"])
|
|
cancel_shortcut = hotkey_manager.format_shortcut(settings["hotkeys"]["cancel_operation"])
|
|
|
|
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.
|
|
|
|
4. {cancel_shortcut} - Cancel Operation
|
|
Cancels the current operation (recording or playback) without saving or processing.
|
|
|
|
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)
|
|
|
|
def handle_ai_edit_hotkey(self):
|
|
"""Handle the hotkey for AI editing."""
|
|
# Check if API key is available
|
|
if not self.parent.has_api_key:
|
|
messagebox.showinfo(
|
|
"API Key Required",
|
|
"AI copyediting requires an OpenAI API key.\n\n"
|
|
"Please add your API key in Settings to use this feature."
|
|
)
|
|
return
|
|
|
|
# Check if AI copy editing is enabled in settings
|
|
settings = self.parent.load_settings()
|
|
if not settings.get("chat_gpt_completion", False):
|
|
messagebox.showinfo(
|
|
"AI Copy Editing Disabled",
|
|
"AI copy editing is currently disabled in settings.\n\n"
|
|
"Please enable it in Settings → AI Copyediting before using this feature."
|
|
)
|
|
return
|
|
|
|
# If we have an API key and AI is enabled, proceed with the AI editing
|
|
self.parent.apply_ai_to_input() |