├── LICENSE ├── README.md ├── describekey.py └── images └── ida-describekey-demo.gif /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Vincent Mallet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Describe Key - Quickly learn what a shortcut does 2 | 3 | 4 | ## Overview 5 | 6 | Describe Key is a very simple 7 | [IDA Pro](https://www.hex-rays.com/products/ida/) plugin: invoke it, 8 | press a shortcut, and instantly see what actions are associated with 9 | the shortcut. Quick and easy, call it from anywhere in IDA. 10 | 11 | ![demo](images/ida-describekey-demo.gif) 12 | 13 | Describe Key is a single-file IDAPython plugin with no external 14 | dependencies. As such, it should work on all platforms supported by IDA. 15 | 16 | 17 | ## Installation 18 | 19 | Simply copy `describekey.py` to your plugins directory and restart IDA. 20 | 21 | _(Alternatively, you could execute `describekey.py` as a script and call 22 | `describekey.register_action()` to load it manually)_ 23 | 24 | 25 | ## Usage 26 | 27 | From anywhere in IDA, press `Alt-Shift-K` to bring up the Describe Key 28 | dialog and press keys to your heart's content. Be enlightened as you 29 | discover all what those keys can do! 30 | 31 | Press `Esc` to close the dialog. 32 | 33 | 34 | ## Note 35 | 36 | Not being a Qt expert, I can't guarantee that the conversion from a Qt 37 | keypress event to an IDA shortcut is very robust. It is thus likely you 38 | might bump into some shortcuts that don't get recognized properly and 39 | give you the wrong (or no) action. Please let me know if you run into 40 | such a case. 41 | 42 | 43 | ## Authors 44 | 45 | * Vincent Mallet ([vmallet](https://github.com/vmallet)) -------------------------------------------------------------------------------- /describekey.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Vincent Mallet 2 | # SPDX-License-Identifier: MIT 3 | 4 | """ 5 | DescribeKey: a simple action to display actions associated with a 6 | shortcut, live. 7 | """ 8 | 9 | from collections import defaultdict 10 | from typing import Optional 11 | 12 | from PyQt5 import QtWidgets, QtCore 13 | from PyQt5.QtCore import Qt 14 | from PyQt5.QtGui import QKeyEvent, QKeySequence, QResizeEvent 15 | from PyQt5.QtWidgets import QDialog, QTableWidget, QTableWidgetSelectionRange, \ 16 | QVBoxLayout, QHeaderView, QLabel, QHBoxLayout 17 | 18 | import ida_idaapi 19 | import ida_kernwin 20 | import idaapi 21 | 22 | __author__ = "https://github.com/vmallet" 23 | 24 | # TODO: use some sort of built-in window/dialog and have iDA remember last position 25 | # TODO: seems to be having issues with ". ; : / - = " keys..˚ 26 | 27 | VERSION = 0.1 28 | 29 | DESCRIBE_KEY_ACTION = "DescribeKey" 30 | DESCRIBE_KEY_TEXT = "Describe Key" 31 | DESCRIBE_KEY_SHORTCUT = "Alt-Shift-K" 32 | DESCRIBE_KEY_TOOLTIP = "List all actions associated with a shortcut" 33 | 34 | DEBUG = True 35 | 36 | # Column indices for action table 37 | COL_LABEL = 0 38 | COL_ACTION = 1 39 | COL_SHORTCUT = 2 40 | COL_TOOLTIP = 3 41 | COL_STATE = 4 42 | COL_VISIBILITY = 5 43 | COL_CHECKABLE = 6 44 | COL_CHECKED = 7 45 | COLUMN_COUNT = 8 46 | 47 | DIRECT_KEYS = r",./<>?;':\"[]{}()`~!@#$%^&*-_=+" 48 | """Keys that shouldn't be mapped to a Qt::Key.""" 49 | 50 | 51 | class KeyNamer(object): 52 | """ 53 | Provide a means to get an IDA-compatible shortcut representation of 54 | a key event. 55 | 56 | See keyevent_to_shortcut() 57 | """ 58 | 59 | def __init__(self): 60 | # Key names 61 | self._keymap = dict() 62 | for key, value in vars(Qt).items(): 63 | if isinstance(value, Qt.Key): 64 | self._keymap[value] = key.partition('_')[2] 65 | 66 | # Modifier names. Note: insertion order matters, should match IDA's modifier order 67 | self._modmap = { 68 | Qt.ControlModifier: "Ctrl", 69 | Qt.AltModifier: "Alt", 70 | Qt.ShiftModifier: "Shift", 71 | Qt.MetaModifier: "Meta", 72 | Qt.GroupSwitchModifier: self._keymap[Qt.Key_AltGr], 73 | Qt.KeypadModifier: self._keymap[Qt.Key_NumLock], 74 | } 75 | 76 | def keyevent_to_shortcut(self, event) -> Optional[str]: 77 | """Attempt to produce IDA-compatible shortcut for keyevent.""" 78 | text = event.text() 79 | if text and text in DIRECT_KEYS: 80 | key = text 81 | else: 82 | # Try to map the key, first using the native virtual key and only 83 | # if it's a legit key like "A" or "F1", not "guillemotleft" or "cent" 84 | native = self._keymap.get(event.nativeVirtualKey(), None) 85 | if native and len(native) > 2: 86 | native = None 87 | # If we don't have a simple key, try to map the actual event key and 88 | # if all else fails, use the event text 89 | key = native or self._keymap.get(event.key(), text) 90 | 91 | if key in [None, "Control", "Alt", "Shift", "Meta"]: 92 | return None 93 | 94 | if event.modifiers() == Qt.ShiftModifier and key in DIRECT_KEYS: 95 | # A bit hacky here.. IDA looks at '%' as a non-shifted shortcut, but 96 | # on US keyboards you would need shift to produce '%'. So if the only 97 | # modifier used with a 'direct key' is Shift, ignore it. This might or 98 | # might not work for other locales. 99 | return key 100 | 101 | sequence = [] 102 | for modifier, text in self._modmap.items(): 103 | if event.modifiers() & modifier: 104 | sequence.append(text) 105 | if key not in sequence: 106 | sequence.append(key) 107 | return '-'.join(sequence) 108 | 109 | 110 | class ActionInfo(object): 111 | """Description of a registered action.""" 112 | 113 | def __init__(self, label, action, shortcut, tooltip, state, visibility, checkable, checked): 114 | self.label = label 115 | self.action = action 116 | self.shortcut = shortcut 117 | self.tooltip = tooltip 118 | self.state = state 119 | self.visibility = visibility 120 | self.checkable = checkable 121 | self.checked = checked 122 | 123 | @classmethod 124 | def for_action(cls, action): 125 | """Construct an ActionInfo for the given action name.""" 126 | 127 | def unw(ret): 128 | return ret[1] if ret and ret[0] else None 129 | 130 | shortcut = ida_kernwin.get_action_shortcut(action) 131 | label = ida_kernwin.get_action_label(action) 132 | tooltip = ida_kernwin.get_action_tooltip(action) 133 | state = unw(ida_kernwin.get_action_state(action)) 134 | visibility = unw(ida_kernwin.get_action_visibility(action)) 135 | checkable = unw(ida_kernwin.get_action_checkable(action)) 136 | checked = unw(ida_kernwin.get_action_checked(action)) 137 | 138 | return ActionInfo(label, action, shortcut, tooltip, state, visibility, checkable, 139 | checked) 140 | 141 | 142 | class DescribeKey(object): 143 | """ 144 | A custom dialog that will show all actions registered with a 145 | shortcut when one is pressed. 146 | 147 | Actions matching a shortcut are displayed in a QTableWidget. 148 | 149 | Key-presses are intercepted by the table widget with a custom 150 | keyPressEvent() handler, and turned into shortcuts that can 151 | be used to look up the corresponding actions. 152 | 153 | ESC to exit. 154 | """ 155 | 156 | def __init__(self): 157 | self._namer = KeyNamer() 158 | self._astmap = self._build_ast_map() 159 | self._shortcut_label = QLabel("Press a shortcut...") 160 | self._table = self._build_table() 161 | self._dialog = self._build_dialog() 162 | self._overlay = self._build_overlay() 163 | 164 | def _build_ast_map(self): 165 | """Build a Value->Name map of all AST_xxx enum values.""" 166 | astmap = { 167 | ida_kernwin.AST_ENABLE: "Enable", 168 | ida_kernwin.AST_ENABLE_ALWAYS: "Enable Always", 169 | ida_kernwin.AST_ENABLE_FOR_WIDGET: "Enable for Widget", 170 | ida_kernwin.AST_ENABLE_FOR_IDB: "Enable for IDB", 171 | ida_kernwin.AST_DISABLE: "Disable", 172 | ida_kernwin.AST_DISABLE_ALWAYS: "Disable Always", 173 | ida_kernwin.AST_DISABLE_FOR_WIDGET: "Disable for Widget", 174 | ida_kernwin.AST_DISABLE_FOR_IDB: "Disable for IDB", 175 | } 176 | return astmap 177 | 178 | def _build_action_map(self): 179 | """Return a Shortcut->Action map of all registered actions.""" 180 | action_map = defaultdict(list) 181 | 182 | actions = ida_kernwin.get_registered_actions() 183 | for name in actions: 184 | shortcut = ida_kernwin.get_action_shortcut(name) 185 | if shortcut: 186 | action_map[shortcut].append(name) 187 | 188 | return action_map 189 | 190 | def _handle_keyevent(self, event: QKeyEvent, action_map, fn): 191 | """Intercept key events and update UI with related actions.""" 192 | # First, clear the overlay 193 | self._dismiss_overlay() 194 | 195 | shortcut = self._namer.keyevent_to_shortcut(event) 196 | self._set_shortcut(shortcut) 197 | if DEBUG: 198 | print("evt: {} key: {:7X} native key: {:2X} native scancode: {:2X} " 199 | "text: {:1} shortcut: {}".format( 200 | type(event), event.key(), event.nativeVirtualKey(), event.nativeScanCode(), 201 | event.text(), shortcut)) 202 | 203 | actions = action_map.get(shortcut, []) if shortcut else [] 204 | self._set_data(actions) 205 | 206 | # Only the ESC key goes through 207 | if event.matches(QKeySequence.Cancel): 208 | fn(event) 209 | 210 | def _build_table(self) -> QTableWidget: 211 | """Construct the table widget used to display actions.""" 212 | table = QtWidgets.QTableWidget() 213 | table.setColumnCount(COLUMN_COUNT) 214 | table.setRowCount(0) 215 | 216 | table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) 217 | table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) 218 | table.setRangeSelected(QTableWidgetSelectionRange(0, 0, 0, 0), True) 219 | table.setShowGrid(False) 220 | 221 | table.setHorizontalHeaderItem(COL_LABEL, QtWidgets.QTableWidgetItem("Label")) 222 | table.setHorizontalHeaderItem(COL_ACTION, QtWidgets.QTableWidgetItem("Action")) 223 | table.setHorizontalHeaderItem(COL_SHORTCUT, QtWidgets.QTableWidgetItem("Shortcut")) 224 | table.setHorizontalHeaderItem(COL_TOOLTIP, QtWidgets.QTableWidgetItem("Description")) 225 | table.setHorizontalHeaderItem(COL_STATE, QtWidgets.QTableWidgetItem("State")) 226 | table.setHorizontalHeaderItem(COL_VISIBILITY, QtWidgets.QTableWidgetItem("V")) 227 | table.setHorizontalHeaderItem(COL_CHECKABLE, QtWidgets.QTableWidgetItem("C")) 228 | table.setHorizontalHeaderItem(COL_CHECKED, QtWidgets.QTableWidgetItem("Cd")) 229 | 230 | # Magic calculation in attempt to size the State column more or less sensibly 231 | state_width = int(table.fontMetrics().width("Disable for Widget") * 1.2) + 5 232 | 233 | table.horizontalHeader().setStretchLastSection(False) 234 | table.horizontalHeader().setSectionResizeMode(COL_TOOLTIP, QHeaderView.Stretch) 235 | 236 | table.setColumnWidth(COL_SHORTCUT, 88) 237 | table.setColumnWidth(COL_STATE, state_width) 238 | table.setColumnWidth(COL_VISIBILITY, 25) 239 | table.setColumnWidth(COL_CHECKABLE, 25) 240 | table.setColumnWidth(COL_CHECKED, 25) 241 | 242 | for i in range(COLUMN_COUNT): 243 | table.horizontalHeaderItem(i).setTextAlignment(QtCore.Qt.AlignLeft) 244 | 245 | table.verticalHeader().setHidden(True) 246 | table.verticalHeader().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents) 247 | table.verticalHeader().setMaximumSectionSize(19) # TODO: '19' magic constant 248 | 249 | # We build the action map once for the lifetime of the dialog 250 | action_map = self._build_action_map() 251 | old_kp = table.keyPressEvent 252 | table.keyPressEvent = lambda evt: self._handle_keyevent(evt, action_map, old_kp) 253 | 254 | old_resize = table.resizeEvent 255 | def resizeEvent(evt: QResizeEvent): 256 | """Size LABEL and ACTION columns proportionally to table width.""" 257 | width = evt.size().width() 258 | if width != evt.oldSize().width(): 259 | self._table.setColumnWidth(COL_LABEL, width / 5) 260 | self._table.setColumnWidth(COL_ACTION, width / 5) 261 | old_resize(evt) 262 | 263 | table.resizeEvent = resizeEvent 264 | 265 | return table 266 | 267 | def _set_shortcut(self, shortcut): 268 | """Set the current shortcut being displayed by the UI.""" 269 | self._shortcut_label.setText(shortcut) 270 | 271 | def _set_data(self, actions): 272 | """Set the actions being displayed by the UI (in the table).""" 273 | self._table.clearContents() 274 | self._table.setRowCount(len(actions)) 275 | 276 | for i, action in enumerate(actions): 277 | info = ActionInfo.for_action(action) 278 | 279 | # Label 280 | item = QtWidgets.QTableWidgetItem() 281 | item.setData(QtCore.Qt.DisplayRole, info.label) 282 | self._table.setItem(i, COL_LABEL, item) 283 | 284 | # Action 285 | item = QtWidgets.QTableWidgetItem() 286 | item.setData(QtCore.Qt.DisplayRole, info.action) 287 | self._table.setItem(i, COL_ACTION, item) 288 | 289 | # Shortcut 290 | item = QtWidgets.QTableWidgetItem() 291 | item.setData(QtCore.Qt.DisplayRole, info.shortcut) 292 | self._table.setItem(i, COL_SHORTCUT, item) 293 | 294 | # Tooltip 295 | item = QtWidgets.QTableWidgetItem() 296 | item.setData(QtCore.Qt.DisplayRole, info.tooltip) 297 | self._table.setItem(i, COL_TOOLTIP, item) 298 | 299 | # State 300 | item = QtWidgets.QTableWidgetItem() 301 | item.setData(QtCore.Qt.DisplayRole, 302 | self._astmap.get(info.state, None) or str(info.state)) 303 | self._table.setItem(i, COL_STATE, item) 304 | 305 | # Visibility 306 | item = QtWidgets.QTableWidgetItem() 307 | item.setData(QtCore.Qt.DisplayRole, "V" if info.visibility else "") 308 | self._table.setItem(i, COL_VISIBILITY, item) 309 | 310 | # Checkable 311 | item = QtWidgets.QTableWidgetItem() 312 | item.setData(QtCore.Qt.DisplayRole, "Y" if info.checkable else "") 313 | self._table.setItem(i, COL_CHECKABLE, item) 314 | 315 | # Checked 316 | item = QtWidgets.QTableWidgetItem() 317 | item.setData(QtCore.Qt.DisplayRole, "Y" if info.checked else "") 318 | self._table.setItem(i, COL_CHECKED, item) 319 | 320 | def _build_status(self): 321 | """Construct the status line for the UI.""" 322 | layout = QHBoxLayout() 323 | layout.addWidget(QLabel("Shortcut: ")) 324 | layout.addWidget(self._shortcut_label) 325 | layout.addStretch() 326 | layout.setContentsMargins(5, 5, 5, 5) 327 | return layout 328 | 329 | def _build_dialog(self): 330 | """Construct the main UI dialog.""" 331 | dialog = QDialog() 332 | dialog.setWindowTitle("Describe Key") 333 | dialog.resize(800, 200) 334 | 335 | layout = QVBoxLayout() 336 | layout.setContentsMargins(0, 0, 0, 0) 337 | layout.addWidget(self._table) 338 | layout.addLayout(self._build_status()) 339 | dialog.setLayout(layout) 340 | 341 | return dialog 342 | 343 | def _build_overlay(self): 344 | """ 345 | Construct the initial help overlay. 346 | 347 | The overlay is a 'floating' widget, child of the dialog. It 348 | will be raised on top of the other widgets (the table), and 349 | hidden upon the first keypress received. 350 | """ 351 | label = QLabel("Press a shortcut...", self._dialog) 352 | label.setStyleSheet("color : #CC3030; font-size: 17px; font-style: italic") 353 | label.adjustSize() 354 | 355 | px = (self._dialog.width() - label.width()) / 2 356 | label.move(px, 50) 357 | label.show() 358 | label.raise_() 359 | 360 | return label 361 | 362 | def _dismiss_overlay(self): 363 | """Hide the help overlay, if it exists.""" 364 | if self._overlay: 365 | self._overlay.hide() 366 | self._overlay = None 367 | 368 | def show(self): 369 | """Show the main UI dialog.""" 370 | self._dialog.exec() 371 | 372 | 373 | class KeyActionHandler(ida_kernwin.action_handler_t): 374 | 375 | def activate(self, ctx): 376 | # Build a new dialog for every invocation of the action 377 | dk = DescribeKey() 378 | dk.show() 379 | return False 380 | 381 | def update(self, ctx): 382 | return ida_kernwin.AST_ENABLE_ALWAYS 383 | 384 | 385 | def unregister_action(): 386 | """Unregister the DescribeKey action from IDA.""" 387 | ida_kernwin.unregister_action(DESCRIBE_KEY_ACTION) 388 | 389 | def register_action(): 390 | """Register the DescribeKey action with IDA.""" 391 | unregister_action() 392 | desc = ida_kernwin.action_desc_t( 393 | DESCRIBE_KEY_ACTION, 394 | DESCRIBE_KEY_TEXT, 395 | KeyActionHandler(), 396 | DESCRIBE_KEY_SHORTCUT, 397 | DESCRIBE_KEY_TOOLTIP) 398 | return ida_kernwin.register_action(desc) 399 | 400 | 401 | class DescribeKeyPlugin(ida_idaapi.plugin_t): 402 | flags = idaapi.PLUGIN_FIX # Always stay loaded, even without an IDB 403 | wanted_name = DESCRIBE_KEY_TEXT 404 | wanted_hotkey = DESCRIBE_KEY_SHORTCUT 405 | comment = DESCRIBE_KEY_TOOLTIP 406 | help = "" 407 | version = VERSION 408 | 409 | def init(self): 410 | return ida_idaapi.PLUGIN_KEEP # keep us in the memory 411 | 412 | def term(self): 413 | pass 414 | 415 | def run(self, arg): 416 | dk = DescribeKey() 417 | dk.show() 418 | 419 | 420 | def PLUGIN_ENTRY(): 421 | """Plugin entry point when loaded as a plugin by IDA.""" 422 | return DescribeKeyPlugin() 423 | -------------------------------------------------------------------------------- /images/ida-describekey-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmallet/ida-describekey/0b781202d3c835001c063659a088d79f68714e82/images/ida-describekey-demo.gif --------------------------------------------------------------------------------