help.py (19457B)
1 from collections import defaultdict 2 import itertools 3 import math 4 from typing import Dict, List, Iterable, Set, Tuple, Union 5 6 from talon import Module, Context, actions, imgui, Module, registry, ui, app 7 from talon.grammar import Phrase 8 9 mod = Module() 10 mod.list("help_contexts", desc="list of available contexts") 11 mod.mode("help", "mode for commands that are available only when help is visible") 12 setting_help_max_contexts_per_page = mod.setting( 13 "help_max_contexts_per_page", 14 type=int, 15 default=20, 16 desc="Max contexts to display per page in help", 17 ) 18 setting_help_max_command_lines_per_page = mod.setting( 19 "help_max_command_lines_per_page", 20 type=int, 21 default=50, 22 desc="Max lines of command to display per page in help", 23 ) 24 25 ctx = Context() 26 # context name -> commands 27 context_command_map = {} 28 29 # rule word -> Set[(context name, rule)] 30 rule_word_map: Dict[str, Set[Tuple[str, str]]] = defaultdict(set) 31 search_phrase = None 32 33 # context name -> actual context 34 context_map = {} 35 36 current_context_page = 1 37 sorted_context_map_keys = [] 38 39 selected_context = None 40 selected_context_page = 1 41 42 total_page_count = 1 43 44 cached_active_contexts_list = [] 45 46 live_update = True 47 cached_window_title = None 48 show_enabled_contexts_only = False 49 50 51 def update_title(): 52 global live_update 53 global show_enabled_contexts_only 54 global cached_window_title 55 56 if live_update: 57 if gui_context_help.showing: 58 if selected_context == None: 59 refresh_context_command_map(show_enabled_contexts_only) 60 else: 61 update_active_contexts_cache(registry.active_contexts()) 62 63 64 # todo: dynamic rect? 65 @imgui.open(y=0) 66 def gui_alphabet(gui: imgui.GUI): 67 global alphabet 68 gui.text("Alphabet help") 69 gui.line() 70 71 for key, val in alphabet.items(): 72 gui.text("{}: {}".format(val, key)) 73 74 gui.spacer() 75 if gui.button("close"): 76 gui_alphabet.hide() 77 78 79 def format_context_title(context_name: str) -> str: 80 global cached_active_contexts_list 81 return "{} [{}]".format( 82 context_name, 83 "ACTIVE" 84 if context_map.get(context_name, None) in cached_active_contexts_list 85 else "INACTIVE", 86 ) 87 88 89 def format_context_button(index: int, context_label: str, context_name: str) -> str: 90 global cached_active_contexts_list 91 global show_enabled_contexts_only 92 93 if not show_enabled_contexts_only: 94 return "{}. {}{}".format( 95 index, 96 context_label, 97 "*" 98 if context_map.get(context_name, None) in cached_active_contexts_list 99 else "", 100 ) 101 else: 102 return "{}. {} ".format(index, context_label) 103 104 105 # translates 1-based index -> actual index in sorted_context_map_keys 106 def get_context_page(index: int) -> int: 107 return math.ceil(index / setting_help_max_contexts_per_page.get()) 108 109 110 def get_total_context_pages() -> int: 111 return math.ceil( 112 len(sorted_context_map_keys) / setting_help_max_contexts_per_page.get() 113 ) 114 115 116 def get_current_context_page_length() -> int: 117 start_index = (current_context_page - 1) * setting_help_max_contexts_per_page.get() 118 return len( 119 sorted_context_map_keys[ 120 start_index : start_index + setting_help_max_contexts_per_page.get() 121 ] 122 ) 123 124 125 def get_command_line_count(command: Tuple[str, str]) -> int: 126 """This should be kept in sync with draw_commands 127 """ 128 _, body = command 129 lines = len(body.split("\n")) 130 if lines == 1: 131 return 1 132 else: 133 return lines + 1 134 135 136 def get_pages(item_line_counts: List[int]) -> List[int]: 137 """Given some set of indivisible items with given line counts, 138 return the page number each item should appear on. 139 140 If an item will cross a page boundary, it is moved to the next page, 141 so that pages may be shorter than the maximum lenth, but not longer. The only 142 exception is when an item is longer than the maximum page length, in which 143 case that item will be placed on a longer page. 144 """ 145 current_page_line_count = 0 146 current_page = 1 147 pages = [] 148 for line_count in item_line_counts: 149 if ( 150 line_count + current_page_line_count 151 > setting_help_max_command_lines_per_page.get() 152 ): 153 if current_page_line_count == 0: 154 # Special case, render a larger page. 155 page = current_page 156 current_page_line_count = 0 157 else: 158 page = current_page + 1 159 current_page_line_count = line_count 160 current_page += 1 161 else: 162 current_page_line_count += line_count 163 page = current_page 164 pages.append(page) 165 return pages 166 167 168 @imgui.open(y=0) 169 def gui_context_help(gui: imgui.GUI): 170 global context_command_map 171 global current_context_page 172 global selected_context 173 global selected_context_page 174 global sorted_context_map_keys 175 global show_enabled_contexts_only 176 global cached_active_contexts_list 177 global total_page_count 178 global search_phrase 179 180 # if no selected context, draw the contexts 181 if selected_context is None and search_phrase is None: 182 total_page_count = get_total_context_pages() 183 184 if not show_enabled_contexts_only: 185 gui.text( 186 "Help: All ({}/{}) (* = active)".format( 187 current_context_page, total_page_count 188 ) 189 ) 190 else: 191 gui.text( 192 "Help: Active Contexts Only ({}/{})".format( 193 current_context_page, total_page_count 194 ) 195 ) 196 197 gui.line() 198 199 current_item_index = 1 200 current_selection_index = 1 201 for key in sorted_context_map_keys: 202 if key in ctx.lists["self.help_contexts"]: 203 target_page = get_context_page(current_item_index) 204 205 if current_context_page == target_page: 206 button_name = format_context_button( 207 current_selection_index, 208 key, 209 ctx.lists["self.help_contexts"][key], 210 ) 211 212 if gui.button(button_name): 213 selected_context = ctx.lists["self.help_contexts"][key] 214 current_selection_index = current_selection_index + 1 215 216 current_item_index += 1 217 218 if total_page_count > 1: 219 gui.spacer() 220 if gui.button("Next..."): 221 actions.user.help_next() 222 223 if gui.button("Previous..."): 224 actions.user.help_previous() 225 226 # if there's a selected context, draw the commands for it 227 else: 228 if selected_context is not None: 229 draw_context_commands(gui) 230 elif search_phrase is not None: 231 draw_search_commands(gui) 232 233 gui.spacer() 234 if total_page_count > 1: 235 if gui.button("Next..."): 236 actions.user.help_next() 237 238 if gui.button("Previous..."): 239 actions.user.help_previous() 240 241 if gui.button("Return"): 242 actions.user.help_return() 243 244 if gui.button("Refresh"): 245 actions.user.help_refresh() 246 247 if gui.button("Close"): 248 actions.user.help_hide() 249 250 251 def draw_context_commands(gui: imgui.GUI): 252 global selected_context 253 global total_page_count 254 global selected_context_page 255 256 context_title = format_context_title(selected_context) 257 title = f"Context: {context_title}" 258 commands = context_command_map[selected_context].items() 259 item_line_counts = [get_command_line_count(command) for command in commands] 260 pages = get_pages(item_line_counts) 261 total_page_count = max(pages, default=1) 262 draw_commands_title(gui, title) 263 264 filtered_commands = [ 265 command 266 for command, page in zip(commands, pages) 267 if page == selected_context_page 268 ] 269 270 draw_commands(gui, filtered_commands) 271 272 273 def draw_search_commands(gui: imgui.GUI): 274 global search_phrase 275 global total_page_count 276 global cached_active_contexts_list 277 global selected_context_page 278 279 title = f"Search: {search_phrase}" 280 commands_grouped = get_search_commands(search_phrase) 281 commands_flat = list(itertools.chain.from_iterable(commands_grouped.values())) 282 283 sorted_commands_grouped = sorted( 284 commands_grouped.items(), 285 key=lambda item: context_map[item[0]] not in cached_active_contexts_list, 286 ) 287 288 pages = get_pages( 289 [ 290 sum(get_command_line_count(command) for command in commands) + 3 291 for _, commands in sorted_commands_grouped 292 ] 293 ) 294 total_page_count = max(pages, default=1) 295 296 draw_commands_title(gui, title) 297 298 current_item_index = 1 299 for (context, commands), page in zip(sorted_commands_grouped, pages): 300 if page == selected_context_page: 301 gui.text(format_context_title(context)) 302 gui.line() 303 draw_commands(gui, commands) 304 gui.spacer() 305 306 307 def get_search_commands(phrase: str) -> Dict[str, Tuple[str, str]]: 308 global rule_word_map 309 tokens = search_phrase.split(" ") 310 311 viable_commands = rule_word_map[tokens[0]] 312 for token in tokens[1:]: 313 viable_commands &= rule_word_map[token] 314 315 commands_grouped = defaultdict(list) 316 for context, rule in viable_commands: 317 command = context_command_map[context][rule] 318 commands_grouped[context].append((rule, command)) 319 320 return commands_grouped 321 322 323 def draw_commands_title(gui: imgui.GUI, title: str): 324 global selected_context_page 325 global total_page_count 326 327 gui.text("{} ({}/{})".format(title, selected_context_page, total_page_count)) 328 gui.line() 329 330 331 def draw_commands(gui: imgui.GUI, commands: Iterable[Tuple[str, str]]): 332 for key, val in commands: 333 val = val.split("\n") 334 if len(val) > 1: 335 gui.text("{}:".format(key)) 336 for line in val: 337 gui.text(" {}".format(line)) 338 else: 339 gui.text("{}: {}".format(key, val[0])) 340 341 342 def reset(): 343 global current_context_page 344 global sorted_context_map_keys 345 global selected_context 346 global search_phrase 347 global selected_context_page 348 global cached_window_title 349 global show_enabled_contexts_only 350 351 current_context_page = 1 352 sorted_context_map_keys = None 353 selected_context = None 354 search_phrase = None 355 selected_context_page = 1 356 cached_window_title = None 357 show_enabled_contexts_only = False 358 359 360 def update_active_contexts_cache(active_contexts): 361 # print("update_active_contexts_cache") 362 global cached_active_contexts_list 363 cached_active_contexts_list = active_contexts 364 365 366 # example usage todo: make a list definable in .talon 367 # overrides = {"generic browser" : "broswer"} 368 overrides = {} 369 370 371 def refresh_context_command_map(enabled_only=False): 372 global rule_word_map 373 global context_command_map 374 global context_map 375 global sorted_context_map_keys 376 global show_enabled_contexts_only 377 global cached_window_title 378 global context_map 379 380 context_map = {} 381 cached_short_context_names = {} 382 show_enabled_contexts_only = enabled_only 383 cached_window_title = ui.active_window().title 384 active_contexts = registry.active_contexts() 385 # print(str(active_contexts)) 386 update_active_contexts_cache(active_contexts) 387 388 context_command_map = {} 389 for context_name, context in registry.contexts.items(): 390 splits = context_name.split(".") 391 index = -1 392 if "talon" in splits[index]: 393 index = -2 394 short_name = splits[index].replace("_", " ") 395 else: 396 short_name = splits[index].replace("_", " ") 397 398 if "mac" == short_name or "win" == short_name or "linux" == short_name: 399 index = index - 1 400 short_name = splits[index].replace("_", " ") 401 402 # print("short name: " + short_name) 403 if short_name in overrides: 404 short_name = overrides[short_name] 405 406 if enabled_only and context in active_contexts or not enabled_only: 407 context_command_map[context_name] = {} 408 for command_alias, val in context.commands.items(): 409 # print(str(val)) 410 if command_alias in registry.commands: 411 # print(str(val.rule.rule) + ": " + val.target.code) 412 context_command_map[context_name][ 413 str(val.rule.rule) 414 ] = val.target.code 415 # print(short_name) 416 # print("length: " + str(len(context_command_map[context_name]))) 417 if len(context_command_map[context_name]) == 0: 418 context_command_map.pop(context_name) 419 else: 420 cached_short_context_names[short_name] = context_name 421 context_map[context_name] = context 422 423 refresh_rule_word_map(context_command_map) 424 425 ctx.lists["self.help_contexts"] = cached_short_context_names 426 # print(str(ctx.lists["self.help_contexts"])) 427 sorted_context_map_keys = sorted(cached_short_context_names) 428 429 430 def refresh_rule_word_map(context_command_map): 431 global rule_word_map 432 rule_word_map = defaultdict(set) 433 434 for context_name, commands in context_command_map.items(): 435 for rule in commands: 436 tokens = set(token for token in rule.split(" ") if token.isalpha()) 437 for token in tokens: 438 rule_word_map[token].add((context_name, rule)) 439 440 441 events_registered = False 442 443 444 def register_events(register: bool): 445 global events_registered 446 if register: 447 if not events_registered and live_update: 448 events_registered = True 449 # registry.register('post:update_contexts', contexts_updated) 450 registry.register("update_commands", commands_updated) 451 else: 452 events_registered = False 453 # registry.unregister('post:update_contexts', contexts_updated) 454 registry.unregister("update_commands", commands_updated) 455 456 457 @mod.action_class 458 class Actions: 459 def help_alphabet(ab: dict): 460 """Provides the alphabet dictionary""" 461 # what you say is stored as a trigger 462 global alphabet 463 alphabet = ab 464 reset() 465 # print("help_alphabet - alphabet gui_alphabet: {}".format(gui_alphabet.showing)) 466 # print( 467 # "help_alphabet - gui_context_help showing: {}".format( 468 # gui_context_help.showing 469 # ) 470 # ) 471 gui_context_help.hide() 472 gui_alphabet.hide() 473 gui_alphabet.show() 474 register_events(False) 475 actions.mode.enable("user.help") 476 477 def help_context_enabled(): 478 """Display contextual command info""" 479 reset() 480 refresh_context_command_map(enabled_only=True) 481 gui_alphabet.hide() 482 gui_context_help.show() 483 register_events(True) 484 actions.mode.enable("user.help") 485 486 def help_context(): 487 """Display contextual command info""" 488 reset() 489 refresh_context_command_map() 490 gui_alphabet.hide() 491 gui_context_help.show() 492 register_events(True) 493 actions.mode.enable("user.help") 494 495 def help_search(phrase: str): 496 """Display command info for search phrase""" 497 global search_phrase 498 499 reset() 500 search_phrase = phrase 501 refresh_context_command_map() 502 gui_alphabet.hide() 503 gui_context_help.show() 504 register_events(True) 505 actions.mode.enable("user.help") 506 507 def help_selected_context(m: str): 508 """Display command info for selected context""" 509 global selected_context 510 global selected_context_page 511 512 if not gui_context_help.showing: 513 reset() 514 refresh_context_command_map() 515 else: 516 selected_context_page = 1 517 update_active_contexts_cache(registry.active_contexts()) 518 519 selected_context = m 520 gui_alphabet.hide() 521 gui_context_help.show() 522 register_events(True) 523 actions.mode.enable("user.help") 524 525 def help_next(): 526 """Navigates to next page""" 527 global current_context_page 528 global selected_context 529 global selected_context_page 530 global total_page_count 531 532 if gui_context_help.showing: 533 if selected_context is None and search_phrase is None: 534 if current_context_page != total_page_count: 535 current_context_page += 1 536 else: 537 current_context_page = 1 538 else: 539 if selected_context_page != total_page_count: 540 selected_context_page += 1 541 else: 542 selected_context_page = 1 543 544 def help_select_index(index: int): 545 """Select the context by a number""" 546 global sorted_context_map_keys, selected_context 547 if gui_context_help.showing: 548 if index < setting_help_max_contexts_per_page.get() and ( 549 (current_context_page - 1) * setting_help_max_contexts_per_page.get() 550 + index 551 < len(sorted_context_map_keys) 552 ): 553 if selected_context is None: 554 selected_context = ctx.lists["self.help_contexts"][ 555 sorted_context_map_keys[ 556 (current_context_page - 1) 557 * setting_help_max_contexts_per_page.get() 558 + index 559 ] 560 ] 561 562 def help_previous(): 563 """Navigates to previous page""" 564 global current_context_page 565 global selected_context 566 global selected_context_page 567 global total_page_count 568 569 if gui_context_help.showing: 570 if selected_context is None and search_phrase is None: 571 if current_context_page != 1: 572 current_context_page -= 1 573 else: 574 current_context_page = total_page_count 575 576 else: 577 if selected_context_page != 1: 578 selected_context_page -= 1 579 else: 580 selected_context_page = total_page_count 581 582 def help_return(): 583 """Returns to the main help window""" 584 global selected_context 585 global selected_context_page 586 global show_enabled_contexts_only 587 588 if gui_context_help.showing: 589 refresh_context_command_map(show_enabled_contexts_only) 590 selected_context_page = 1 591 selected_context = None 592 593 def help_refresh(): 594 """Refreshes the help""" 595 global show_enabled_contexts_only 596 global selected_context 597 598 if gui_context_help.showing: 599 if selected_context == None: 600 refresh_context_command_map(show_enabled_contexts_only) 601 else: 602 update_active_contexts_cache(registry.active_contexts()) 603 604 def help_hide(): 605 """Hides the help""" 606 reset() 607 608 # print("help_hide - alphabet gui_alphabet: {}".format(gui_alphabet.showing)) 609 # print( 610 # "help_hide - gui_context_help showing: {}".format(gui_context_help.showing) 611 # ) 612 613 gui_alphabet.hide() 614 gui_context_help.hide() 615 refresh_context_command_map() 616 register_events(False) 617 actions.mode.disable("user.help") 618 619 620 def commands_updated(_): 621 update_title() 622 623 624 app.register("ready", refresh_context_command_map) 625