Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(gui): add global shortcuts management #317

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 245 additions & 0 deletions basilisk/gui/global_shortcuts_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import logging
from typing import Dict, Optional, Tuple

import win32api
import win32con
import wx

from basilisk.hotkeys import MODIFIER_MAP, get_base_vk_code, shortcut_to_string

log = logging.getLogger(__name__)


WXK_TO_VK_CODE_MAP: Dict[int, int] = {
wx.WXK_ESCAPE: win32con.VK_ESCAPE,
wx.WXK_F1: win32con.VK_F1,
wx.WXK_F2: win32con.VK_F2,
wx.WXK_F3: win32con.VK_F3,
wx.WXK_F4: win32con.VK_F4,
wx.WXK_F5: win32con.VK_F5,
wx.WXK_F6: win32con.VK_F6,
wx.WXK_F7: win32con.VK_F7,
wx.WXK_F8: win32con.VK_F8,
wx.WXK_F9: win32con.VK_F9,
wx.WXK_F10: win32con.VK_F10,
wx.WXK_F11: win32con.VK_F11,
wx.WXK_F12: win32con.VK_F12,
wx.WXK_SPACE: win32con.VK_SPACE,
wx.WXK_PAGEUP: win32con.VK_PRIOR,
wx.WXK_PAGEDOWN: win32con.VK_NEXT,
wx.WXK_END: win32con.VK_END,
wx.WXK_HOME: win32con.VK_HOME,
wx.WXK_LEFT: win32con.VK_LEFT,
wx.WXK_UP: win32con.VK_UP,
wx.WXK_RIGHT: win32con.VK_RIGHT,
wx.WXK_DOWN: win32con.VK_DOWN,
wx.WXK_INSERT: win32con.VK_INSERT,
wx.WXK_DELETE: win32con.VK_DELETE,
}


def get_vk_code(event: wx.KeyEvent) -> Optional[int]:
"""Converts a WX key code to a VK code."""
# Try raw key code first
raw_key = event.GetRawKeyCode()
if raw_key:
return raw_key

# Then try standard wx key code mapping
wx_key = event.GetKeyCode()
if wx_key in WXK_TO_VK_CODE_MAP:
return WXK_TO_VK_CODE_MAP[wx_key]

# For normal characters, try to map unicode to VK
if 32 <= wx_key <= 255:
try:
vk_code = win32api.VkKeyScan(chr(wx_key))
if vk_code != -1:
return vk_code & 0xFF
except Exception:
pass

# For special characters, use the raw key flags
raw_flags = event.GetRawKeyFlags()
if raw_flags:
return raw_flags & 0xFF

return None


class GlobalShortcutsDialog(wx.Dialog):
def __init__(self, parent: Optional[wx.Window] = None):
super().__init__(
parent,
# Translators: A dialog title for global shortcuts configuration.
title=_("Global Shortcuts"),
size=(350, 250),
)
self.current_shortcuts = {
# Translators: An action name for toggling the visibility of the main window.
_("Minimize/Restore"): (
win32con.MOD_WIN | win32con.MOD_ALT,
win32con.VK_SPACE,
),
# Translators: An action name for capturing the entire screen.
_("Capture Fullscreen"): (
win32con.MOD_WIN | win32con.MOD_CONTROL,
ord('U'),
),
_("Capture Active Window"): (
win32con.MOD_WIN | win32con.MOD_CONTROL,
ord('W'),
),
}
self.init_ui()
self.update_ui()

def init_ui(self) -> None:
panel = wx.Panel(self)
main_sizer = wx.BoxSizer(wx.VERTICAL)

self.action_list = wx.ListCtrl(panel, style=wx.LC_REPORT)
self.action_list.InsertColumn(0, _("Action"), width=180)
self.action_list.InsertColumn(1, _("Shortcut"), width=140)
self.action_list.Bind(wx.EVT_KEY_DOWN, self.on_action_list)

assign_btn = wx.Button(panel, label=_("Assign"))
assign_btn.Bind(wx.EVT_BUTTON, self.on_set_shortcut)

close_button = wx.Button(panel, wx.ID_CLOSE)
close_button.Bind(wx.EVT_BUTTON, self.on_close)
self.SetEscapeId(wx.ID_CLOSE)

main_sizer.Add(
self.action_list, proportion=1, flag=wx.EXPAND | wx.ALL, border=5
)
main_sizer.Add(assign_btn, flag=wx.ALIGN_CENTER | wx.ALL, border=5)
main_sizer.Add(close_button, flag=wx.ALIGN_CENTER | wx.ALL, border=5)

panel.SetSizer(main_sizer)

def populate_shortcut_list(self) -> None:
self.action_list.DeleteAllItems()
for idx, (action, shortcut) in enumerate(
self.current_shortcuts.items()
):
self.action_list.InsertItem(idx, action)
self.action_list.SetItem(idx, 1, shortcut_to_string(shortcut))

def update_ui(self) -> None:
self.populate_shortcut_list()

def on_action_list(self, event: wx.KeyEvent) -> None:
if event.GetKeyCode() == wx.WXK_RETURN:
self.on_set_shortcut(event)
else:
event.Skip()

def on_set_shortcut(self, event: wx.Event) -> None:
selected_index = self.action_list.GetFirstSelected()
if selected_index == -1:
wx.MessageBox(
_("Please select an action to assign a new shortcut."),
_("Error"),
wx.ICON_ERROR | wx.OK,
)
return

action = self.action_list.GetItemText(selected_index)
dlg = ShortcutCaptureDialog(self)

if dlg.ShowModal() == wx.ID_OK:
new_shortcut = dlg.shortcut
if new_shortcut:
# Unregister old shortcut
old_shortcut = self.current_shortcuts[action]
wx.GetApp().unregister_hotkey(old_shortcut)

# Register new shortcut
if wx.GetApp().register_hotkey(
new_shortcut, self.get_callback_for_action(action)
):
self.current_shortcuts[action] = new_shortcut
wx.MessageBox(
_(
"Captured shortcut for {action}: {new_shortcut}"
).format(
action=action,
new_shortcut=shortcut_to_string(new_shortcut),
),
_("Shortcut Captured"),
wx.OK | wx.ICON_INFORMATION,
)
self.populate_shortcut_list()
self.action_list.Select(selected_index, on=True)
self.action_list.Focus(selected_index)
else:
wx.MessageBox(
_(
"Failed to register the shortcut. It may be already in use."
),
_("Error"),
wx.ICON_ERROR | wx.OK,
)

def get_callback_for_action(self, action: str) -> callable:
"""Return the appropriate callback for an action."""
pass

def on_close(self, event: wx.Event) -> None:
self.EndModal(wx.ID_CLOSE)


class ShortcutCaptureDialog(wx.Dialog):
def __init__(self, parent: Optional[wx.Window] = None):
super().__init__(parent, title=_("Press the Shortcu"), size=(250, 100))
self._shortcut: Optional[Tuple[int, int]] = None
self.init_ui()

def init_ui(self) -> None:
panel = wx.Panel(self)
sizer = wx.BoxSizer(wx.VERTICAL)

instruction = wx.StaticText(
panel, label=_("Press the desired shortcut key combination.")
)
sizer.Add(instruction, flag=wx.EXPAND | wx.ALL, border=5)

panel.SetSizer(sizer)
panel.Bind(wx.EVT_KEY_UP, self.on_key_up)

def on_key_up(self, event: wx.KeyEvent) -> None:
vk_code = get_vk_code(event)

mods = 0
if win32api.GetAsyncKeyState(
win32con.VK_LWIN
) or win32api.GetAsyncKeyState(win32con.VK_RWIN):
mods |= win32con.MOD_WIN
if win32api.GetAsyncKeyState(win32con.VK_MENU): # Alt key
mods |= win32con.MOD_ALT
if win32api.GetAsyncKeyState(win32con.VK_CONTROL):
mods |= win32con.MOD_CONTROL
if win32api.GetAsyncKeyState(win32con.VK_SHIFT):
mods |= win32con.MOD_SHIFT

log.debug(f'Key up: mods={mods}, vk_code={vk_code}')

if mods and vk_code:
# Convert special chars to base form
shortcut = get_base_vk_code((mods, vk_code))
self._shortcut = shortcut
self.EndModal(wx.ID_OK)
else:
log.debug('Invalid shortcut key combination.')
wx.MessageBox(
_(
"Please press a valid key with at least one modifier ({modifiers})."
).format(modifiers=', '.join(MODIFIER_MAP.values())),
_("Invalid Shortcut"),
wx.OK | wx.ICON_WARNING,
)

@property
def shortcut(self) -> Optional[Tuple[int, int]]:
return self._shortcut
25 changes: 20 additions & 5 deletions basilisk/gui/main_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@ def update_item_label_suffix(item: wx.MenuItem, suffix: str = "..."):
preferences_item = tool_menu.Append(wx.ID_PREFERENCES)
self.Bind(wx.EVT_MENU, self.on_preferences, preferences_item)
update_item_label_suffix(preferences_item, "...\tCtrl+,")
global_shortcuts_item = tool_menu.Append(
wx.ID_ANY,
# Translators: A label for a menu item to manage global shortcuts
_("Manage global shortcuts") + "...\tCtrl+Shift+K",
)
self.Bind(
wx.EVT_MENU, self.on_manage_global_shortcuts, global_shortcuts_item
)
tool_menu.AppendSeparator()
install_nvda_addon = tool_menu.Append(
wx.ID_ANY, _("Install NVDA addon")
Expand Down Expand Up @@ -220,17 +228,17 @@ def init_accelerators(self):
def register_hot_key(self):
self.RegisterHotKey(
HotkeyAction.TOGGLE_VISIBILITY.value,
win32con.MOD_CONTROL | win32con.MOD_ALT | win32con.MOD_SHIFT,
ord('B'),
win32con.MOD_WIN | win32con.MOD_ALT,
win32con.VK_SPACE,
)
self.RegisterHotKey(
HotkeyAction.CAPTURE_FULL.value,
win32con.MOD_CONTROL | win32con.MOD_ALT | win32con.MOD_SHIFT,
ord('F'),
win32con.MOD_WIN | win32con.MOD_CONTROL,
ord('U'),
)
self.RegisterHotKey(
HotkeyAction.CAPTURE_WINDOW.value,
win32con.MOD_CONTROL | win32con.MOD_ALT | win32con.MOD_SHIFT,
win32con.MOD_WIN | win32con.MOD_CONTROL,
ord('W'),
)

Expand Down Expand Up @@ -505,6 +513,13 @@ def on_preferences(self, event):
self.refresh_tabs()
preferences_dialog.Destroy()

def on_manage_global_shortcuts(self, event):
from .global_shortcuts_dialog import GlobalShortcutsDialog

global_shortcuts_dialog = GlobalShortcutsDialog(self)
global_shortcuts_dialog.ShowModal()
global_shortcuts_dialog.Destroy()

def on_manage_conversation_profiles(self, event):
from .conversation_profile_dialog import ConversationProfileDialog

Expand Down
Loading
Loading