From 4ed431640e7eadd790baffc3b02ffdd51acf28f6 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 13 Aug 2026 09:29:43 -0400 Subject: [PATCH] Improve tutorial help navigation --- config/keyboard/desktop.conf | 1 - config/keyboard/laptop.conf | 1 - .../commands/commands/00_init_commands.py | 2 + .../commands/cycle_keyboard_layout.py | 105 ------ .../commands/commands/toggle_tutorial_mode.py | 46 ++- .../commands/commands/toggle_vmenu_mode.py | 1 - .../commands/help/next_help_section.py | 30 ++ .../commands/help/prev_help_section.py | 30 ++ src/fenrirscreenreader/core/commandManager.py | 5 +- src/fenrirscreenreader/core/fenrirManager.py | 34 +- src/fenrirscreenreader/core/helpManager.py | 341 ++++++++++++------ src/fenrirscreenreader/core/inputDriver.py | 3 + src/fenrirscreenreader/core/inputManager.py | 15 + .../inputDriver/x11Driver.py | 61 ++++ tests/unit/test_fenrir_manager_shortcuts.py | 92 +++++ tests/unit/test_help_manager.py | 202 +++++++++++ tests/unit/test_review_spelling_commands.py | 8 + tests/unit/test_toggle_vmenu_command.py | 34 ++ tests/unit/test_x11_terminal_mode.py | 75 ++++ tools/fenrir.pot | 58 ++- 20 files changed, 910 insertions(+), 234 deletions(-) delete mode 100644 src/fenrirscreenreader/commands/commands/cycle_keyboard_layout.py create mode 100644 src/fenrirscreenreader/commands/help/next_help_section.py create mode 100644 src/fenrirscreenreader/commands/help/prev_help_section.py create mode 100644 tests/unit/test_help_manager.py create mode 100644 tests/unit/test_toggle_vmenu_command.py diff --git a/config/keyboard/desktop.conf b/config/keyboard/desktop.conf index 7f850bf1..b69a5df3 100644 --- a/config/keyboard/desktop.conf +++ b/config/keyboard/desktop.conf @@ -130,4 +130,3 @@ KEY_FENRIR,KEY_F8=export_clipboard_to_x KEY_FENRIR,KEY_CTRL,KEY_DOWN=read_all_by_line KEY_FENRIR,KEY_CTRL,KEY_PAGEDOWN=read_all_by_page KEY_FENRIR,KEY_SHIFT,KEY_V=announce_fenrir_version -KEY_FENRIR,KEY_LEFTCTRL,KEY_F4=cycle_keyboard_layout diff --git a/config/keyboard/laptop.conf b/config/keyboard/laptop.conf index 4bff806a..b14be74f 100644 --- a/config/keyboard/laptop.conf +++ b/config/keyboard/laptop.conf @@ -130,4 +130,3 @@ KEY_FENRIR,KEY_F8=export_clipboard_to_x KEY_FENRIR,KEY_CTRL,KEY_DOWN=read_all_by_line KEY_FENRIR,KEY_CTRL,KEY_PAGEDOWN=read_all_by_page KEY_FENRIR,KEY_SHIFT,KEY_V=announce_fenrir_version -KEY_FENRIR,KEY_LEFTCTRL,KEY_F4=cycle_keyboard_layout diff --git a/src/fenrirscreenreader/commands/commands/00_init_commands.py b/src/fenrirscreenreader/commands/commands/00_init_commands.py index 5caacb9c..28e9cf5f 100644 --- a/src/fenrirscreenreader/commands/commands/00_init_commands.py +++ b/src/fenrirscreenreader/commands/commands/00_init_commands.py @@ -13,6 +13,8 @@ from fenrirscreenreader.core.i18n import _ class command: + help_visible = False + def __init__(self): pass diff --git a/src/fenrirscreenreader/commands/commands/cycle_keyboard_layout.py b/src/fenrirscreenreader/commands/commands/cycle_keyboard_layout.py deleted file mode 100644 index f83a6cad..00000000 --- a/src/fenrirscreenreader/commands/commands/cycle_keyboard_layout.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import os - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("cycles between available keyboard layouts") - - def get_available_layouts(self): - """Get list of available keyboard layout files""" - layouts = [] - - # Check standard locations for keyboard layouts - settings_root = "/etc/fenrirscreenreader/" - if not os.path.exists(settings_root): - # Fallback to source directory - import fenrirscreenreader - - fenrir_path = os.path.dirname(fenrirscreenreader.__file__) - settings_root = fenrir_path + "/../../config/" - - keyboard_path = settings_root + "keyboard/" - - if os.path.exists(keyboard_path): - for file in os.listdir(keyboard_path): - if ( - file.endswith(".conf") - and not file.startswith("__") - and not file.lower().startswith("pty") - ): - layout_name = file.replace(".conf", "") - if layout_name not in layouts: - layouts.append(layout_name) - - # Ensure we have at least basic layouts - if not layouts: - layouts = ["desktop", "laptop"] - else: - layouts.sort() - - return layouts - - def run(self): - current_layout = self.env["runtime"]["SettingsManager"].get_setting( - "keyboard", "keyboard_layout" - ) - - # Extract layout name from full path if needed - if "/" in current_layout: - current_layout = os.path.basename(current_layout).replace( - ".conf", "" - ) - - # Get available layouts - available_layouts = self.get_available_layouts() - - # Find next layout in cycle - try: - current_index = available_layouts.index(current_layout) - next_index = (current_index + 1) % len(available_layouts) - except ValueError: - # If current layout not found, start from beginning - next_index = 0 - - next_layout = available_layouts[next_index] - - # Update setting and reload shortcuts - self.env["runtime"]["SettingsManager"].set_setting( - "keyboard", "keyboard_layout", next_layout - ) - - # Reload shortcuts with new layout - try: - self.env["runtime"]["InputManager"].reload_shortcuts() - self.env["runtime"]["OutputManager"].present_text( - _("Switched to {} keyboard layout").format(next_layout), - interrupt=True, - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "Error reloading shortcuts: " + str(e), debug.DebugLevel.ERROR - ) - self.env["runtime"]["OutputManager"].present_text( - _("Error switching keyboard layout"), interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py b/src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py index ff18fea9..33ef14ec 100644 --- a/src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py +++ b/src/fenrirscreenreader/commands/commands/toggle_tutorial_mode.py @@ -19,19 +19,47 @@ class command: pass def get_description(self): - self.env["runtime"]["HelpManager"].toggle_tutorial_mode() - return _( - "Exiting tutorial mode. To enter tutorial mode again press Fenrir+f1" - ) + return _("enter or leave tutorial mode") def run(self): - self.env["runtime"]["HelpManager"].toggle_tutorial_mode() - if self.env["runtime"]["HelpManager"].is_tutorial_mode(): + help_manager = self.env["runtime"]["HelpManager"] + was_active = help_manager.is_tutorial_mode() + help_manager.toggle_tutorial_mode() + is_active = help_manager.is_tutorial_mode() + if was_active and is_active: self.env["runtime"]["OutputManager"].present_text( _( - "Entering tutorial mode. In this mode commands are described but not " - "executed. You can move through the list of commands with the up and " - "down arrow keys. To Exit tutorial mode press Fenrir+f1." + "Unable to exit tutorial mode because exclusive keyboard " + "capture could not be released. Press Fenrir+F1 or Escape " + "to try again." + ), + interrupt=True, + ) + elif is_active: + self.env["runtime"]["OutputManager"].present_text( + _( + "Entering tutorial mode. In this mode commands are " + "described but not executed. Use up and down to browse " + "actions and left and right to switch between Active " + "actions, Unbound actions, and Plugins. Press Space to " + "repeat the current item. To exit tutorial mode press " + "Fenrir+F1 or Escape. Active actions." + ), + interrupt=True, + ) + if self.env["runtime"]["HelpManager"].is_capture_degraded(): + self.env["runtime"]["OutputManager"].present_text( + _( + "Warning: full keyboard capture is unavailable. " + "Unbound keys may reach the active application." + ), + interrupt=False, + ) + else: + self.env["runtime"]["OutputManager"].present_text( + _( + "Exiting tutorial mode. To enter tutorial mode again " + "press Fenrir+F1" ), interrupt=True, ) diff --git a/src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py b/src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py index 4560d54f..b0e1e293 100644 --- a/src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py +++ b/src/fenrirscreenreader/commands/commands/toggle_vmenu_mode.py @@ -19,7 +19,6 @@ class command: pass def get_description(self): - self.env["runtime"]["VmenuManager"].toggle_vmenu_mode() return _("Entering or Leaving v menu mode.") def run(self): diff --git a/src/fenrirscreenreader/commands/help/next_help_section.py b/src/fenrirscreenreader/commands/help/next_help_section.py new file mode 100644 index 00000000..4624b62c --- /dev/null +++ b/src/fenrirscreenreader/commands/help/next_help_section.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +from fenrirscreenreader.core.i18n import _ + + +class command: + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def get_description(self): + return _("show next help category") + + def run(self): + text = self.env["runtime"]["HelpManager"].next_help_section() + self.env["runtime"]["OutputManager"].present_text( + text, interrupt=True + ) + + def set_callback(self, callback): + pass diff --git a/src/fenrirscreenreader/commands/help/prev_help_section.py b/src/fenrirscreenreader/commands/help/prev_help_section.py new file mode 100644 index 00000000..3050f36f --- /dev/null +++ b/src/fenrirscreenreader/commands/help/prev_help_section.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +from fenrirscreenreader.core.i18n import _ + + +class command: + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def get_description(self): + return _("show previous help category") + + def run(self): + text = self.env["runtime"]["HelpManager"].prev_help_section() + self.env["runtime"]["OutputManager"].present_text( + text, interrupt=True + ) + + def set_callback(self, callback): + pass diff --git a/src/fenrirscreenreader/core/commandManager.py b/src/fenrirscreenreader/core/commandManager.py index 38f9d585..1b80483b 100644 --- a/src/fenrirscreenreader/core/commandManager.py +++ b/src/fenrirscreenreader/core/commandManager.py @@ -436,7 +436,10 @@ class CommandManager: ) def execute_command(self, command, section="commands"): - if self.env["runtime"]["ScreenManager"].is_ignored_screen(): + if ( + self.env["runtime"]["ScreenManager"].is_ignored_screen() + and not self.env["runtime"]["HelpManager"].is_tutorial_mode() + ): return if self.command_exists(command, section): try: diff --git a/src/fenrirscreenreader/core/fenrirManager.py b/src/fenrirscreenreader/core/fenrirManager.py index ecc0c2a3..cc7ef237 100644 --- a/src/fenrirscreenreader/core/fenrirManager.py +++ b/src/fenrirscreenreader/core/fenrirManager.py @@ -82,16 +82,25 @@ class FenrirManager: else: return - if self.environment["runtime"]["ScreenManager"].is_ignored_screen(): + tutorial_mode = self.environment["runtime"][ + "HelpManager" + ].is_tutorial_mode() + if ( + self.environment["runtime"]["ScreenManager"].is_ignored_screen() + and not tutorial_mode + ): self.environment["runtime"]["InputManager"].write_event_buffer() else: - if self.environment["runtime"]["HelpManager"].is_tutorial_mode(): + if tutorial_mode: self.environment["runtime"][ "InputManager" ].clear_event_buffer() - self.environment["runtime"]["InputManager"].key_echo( - event["data"] - ) + if self.environment["runtime"][ + "ScreenManager" + ].is_ignored_screen(): + self.environment["runtime"]["InputManager"].key_echo( + event["data"] + ) if self.environment["runtime"]["VmenuManager"].get_active(): self.environment["runtime"][ @@ -152,10 +161,15 @@ class FenrirManager: if self.environment["runtime"]["CommandManager"].command_exists( current_command, "help" ): - self.environment["runtime"]["CommandManager"].execute_command( + self.environment["runtime"]["CommandManager"].run_command( current_command, "help" ) return + if current_command == "TOGGLE_TUTORIAL_MODE": + self.environment["runtime"]["CommandManager"].run_command( + current_command, "commands" + ) + return elif self.environment["runtime"]["VmenuManager"].get_active(): if self.environment["runtime"]["CommandManager"].command_exists( current_command, "vmenu-navigation" @@ -324,7 +338,13 @@ class FenrirManager: self.singleKeyCommand = True elif ( ( - self.environment["runtime"]["VmenuManager"].get_active() + ( + "HelpManager" in self.environment["runtime"] + and self.environment["runtime"][ + "HelpManager" + ].is_tutorial_mode() + ) + or self.environment["runtime"]["VmenuManager"].get_active() or self.environment["runtime"][ "DiffReviewManager" ].is_active() diff --git a/src/fenrirscreenreader/core/helpManager.py b/src/fenrirscreenreader/core/helpManager.py index 23053cb7..704bf093 100755 --- a/src/fenrirscreenreader/core/helpManager.py +++ b/src/fenrirscreenreader/core/helpManager.py @@ -4,99 +4,181 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. +import ast +import copy + from fenrirscreenreader.core import debug from fenrirscreenreader.core.i18n import _ class HelpManager: + ACTIVE_SECTION = "active" + UNBOUND_SECTION = "unbound" + PLUGINS_SECTION = "plugins" + HELP_SECTIONS = ( + ACTIVE_SECTION, + UNBOUND_SECTION, + PLUGINS_SECTION, + ) + def __init__(self): - self.helpDict = {} - self.tutorialListIndex = None + self.env = None + self.help_lists = { + self.ACTIVE_SECTION: [], + self.UNBOUND_SECTION: [], + self.PLUGINS_SECTION: [], + } + self.help_section = self.ACTIVE_SECTION + self.tutorial_list_index = None + self.bindings_backup = None + self.raw_bindings_backup = None + self.capture_degraded = False def initialize(self, environment): self.env = environment def shutdown(self): - pass + if self.is_tutorial_mode(): + self.set_tutorial_mode(False) def toggle_tutorial_mode(self): - self.set_tutorial_mode(not self.env["general"]["tutorialMode"]) + self.set_tutorial_mode(not self.is_tutorial_mode()) - def set_tutorial_mode(self, newTutorialMode): - if self.env["runtime"]["VmenuManager"].get_active(): - return - self.env["general"]["tutorialMode"] = newTutorialMode - if newTutorialMode: + def set_tutorial_mode(self, tutorial_mode): + if tutorial_mode == self.is_tutorial_mode(): + return True + if tutorial_mode and self.env["runtime"]["VmenuManager"].get_active(): + return False + + if tutorial_mode: + self.bindings_backup = self.env["bindings"].copy() + self.raw_bindings_backup = copy.deepcopy( + self.env["rawBindings"] + ) self.create_help_dict() - self.env["bindings"][ - str([1, ["KEY_ESC"]]) - ] = "TOGGLE_TUTORIAL_MODE" - self.env["bindings"][str([1, ["KEY_UP"]])] = "PREV_HELP" - self.env["bindings"][str([1, ["KEY_DOWN"]])] = "NEXT_HELP" - self.env["bindings"][str([1, ["KEY_SPACE"]])] = "CURR_HELP" - else: - try: - self.env["bindings"] = self.env["runtime"][ - "SettingsManager" - ].get_binding_backup() - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "HelpManager set_tutorial_mode: Error restoring binding backup: " - + str(e), - debug.DebugLevel.ERROR, - ) + self.env["general"]["tutorialMode"] = True + self._install_help_bindings() + self._refresh_input_bindings() + self.capture_degraded = not self.env["runtime"][ + "InputManager" + ].set_help_capture(True) + return True + + if not self.env["runtime"]["InputManager"].set_help_capture(False): + self.env["runtime"]["DebugManager"].write_debug_out( + "HelpManager could not release exclusive input capture", + debug.DebugLevel.ERROR, + ) + return False + self._restore_bindings() + self.env["general"]["tutorialMode"] = False + self.capture_degraded = False + self.env["runtime"]["InputManager"].reset_input_state() + self._refresh_input_bindings() + return True + + def _install_help_bindings(self): + help_bindings = { + str([1, ["KEY_ESC"]]): "TOGGLE_TUTORIAL_MODE", + str([1, ["KEY_UP"]]): "PREV_HELP", + str([1, ["KEY_DOWN"]]): "NEXT_HELP", + str([1, ["KEY_LEFT"]]): "PREV_HELP_SECTION", + str([1, ["KEY_RIGHT"]]): "NEXT_HELP_SECTION", + str([1, ["KEY_SPACE"]]): "CURR_HELP", + } + self.env["bindings"].update(help_bindings) + for shortcut in help_bindings: + self.env["rawBindings"][shortcut] = ast.literal_eval(shortcut) + + def _restore_bindings(self): + if self.bindings_backup is not None: + self.env["bindings"] = self.bindings_backup + if self.raw_bindings_backup is not None: + self.env["rawBindings"] = self.raw_bindings_backup + self.bindings_backup = None + self.raw_bindings_backup = None + + def _refresh_input_bindings(self): + try: + refresh_grabs = getattr( + self.env["runtime"]["InputDriver"], "refresh_grabs", None + ) + if refresh_grabs: + refresh_grabs(force=True) + except Exception as error: + self.env["runtime"]["DebugManager"].write_debug_out( + "HelpManager could not refresh input bindings: " + + str(error), + debug.DebugLevel.ERROR, + ) def is_tutorial_mode(self): + if self.env is None: + return False return self.env["general"]["tutorialMode"] - def get_formatted_shortcut_for_command(self, command): - shortcut = [] - raw_shortcut = [] - try: - raw_shortcut = list(self.env["bindings"].keys())[ - list(self.env["bindings"].values()).index(command) - ] - raw_shortcut = self.env["rawBindings"][raw_shortcut] - # prefer numbers for multitap - if raw_shortcut[0] in range(2, 9): - formatted_key = str(raw_shortcut[0]) + " times " - shortcut.append(formatted_key) - # prefer metha keys - for k in [ - "KEY_FENRIR", - "KEY_SCRIPT", - "KEY_CTRL", - "KEY_SHIFT", - "KEY_ALT", - "KEY_META", - ]: - if k in raw_shortcut[1]: - formatted_key = k - formatted_key = formatted_key.lower() - formatted_key = formatted_key.replace("key_kp", " keypad ") - formatted_key = formatted_key.replace("key_", " ") - shortcut.append(formatted_key) - raw_shortcut[1].remove(k) - # handle other keys - for k in raw_shortcut[1]: - formatted_key = k - formatted_key = formatted_key.lower() - formatted_key = formatted_key.replace("key_kp", " keypad ") - formatted_key = formatted_key.replace("key_", " ") - shortcut.append(formatted_key) - except Exception as e: - return "" - shortcut = str(shortcut) - shortcut = shortcut.replace("[", "") - shortcut = shortcut.replace("]", "") - shortcut = shortcut.replace("'", "") - return shortcut + def is_capture_degraded(self): + return self.capture_degraded - def get_command_help_text(self, command, section="commands"): - command_name = command.lower() - command_name = command_name.split("__-__")[0] - command_name = command_name.replace("_", " ") - command_name = command_name.replace("_", " ") + def _get_shortcuts_by_command(self): + bindings = ( + self.bindings_backup + if self.bindings_backup is not None + else self.env["bindings"] + ) + raw_bindings = ( + self.raw_bindings_backup + if self.raw_bindings_backup is not None + else self.env["rawBindings"] + ) + shortcuts_by_command = {} + for shortcut_key, command in bindings.items(): + raw_shortcut = raw_bindings.get(shortcut_key) + if raw_shortcut is None: + try: + raw_shortcut = ast.literal_eval(shortcut_key) + except (SyntaxError, ValueError): + continue + shortcuts_by_command.setdefault(command, []).append( + copy.deepcopy(raw_shortcut) + ) + return shortcuts_by_command + + def _format_shortcut(self, raw_shortcut): + shortcut_repeat, raw_keys = raw_shortcut + keys = list(raw_keys) + formatted_keys = [] + repeat_prefix = "" + if shortcut_repeat in range(2, 9): + repeat_prefix = _("{} times ").format(shortcut_repeat) + for key_name in [ + "KEY_FENRIR", + "KEY_SCRIPT", + "KEY_CTRL", + "KEY_SHIFT", + "KEY_ALT", + "KEY_META", + ]: + if key_name in keys: + formatted_keys.append(self._format_key_name(key_name)) + keys.remove(key_name) + formatted_keys.extend(self._format_key_name(key) for key in keys) + return repeat_prefix + ", ".join(formatted_keys) + + def _format_key_name(self, key_name): + formatted_key = key_name.lower() + formatted_key = formatted_key.replace("key_kp", "keypad ") + formatted_key = formatted_key.replace("key_", "") + return _(formatted_key.strip()) + + def get_formatted_shortcut_for_command(self, command): + shortcuts = self._get_shortcuts_by_command().get(command, []) + return "; ".join( + self._format_shortcut(shortcut) for shortcut in shortcuts + ) + + def get_command_help_text(self, command, shortcuts=None): + command_name = command.lower().split("__-__")[0].replace("_", " ") if command == "TOGGLE_TUTORIAL_MODE": command_description = _("toggles the tutorial mode") else: @@ -104,45 +186,96 @@ class HelpManager: "CommandManager" ].get_command_description(command, section="commands") if command_description == "": - command_description = "no Description available" - command_shortcut = self.get_formatted_shortcut_for_command(command) - if command_shortcut == "": - command_shortcut = "unbound" - helptext = ( - command_name - + ", Shortcut " - + command_shortcut - + ", Description " - + command_description + command_description = _("no description available") + if shortcuts is None: + shortcuts = self._get_shortcuts_by_command().get(command, []) + command_shortcuts = "; ".join( + self._format_shortcut(shortcut) for shortcut in shortcuts + ) + if command_shortcuts == "": + command_shortcuts = _("unbound") + return _( + "{command_name}, Shortcuts {command_shortcuts}, Description " + "{command_description}" + ).format( + command_name=command_name, + command_shortcuts=command_shortcuts, + command_description=command_description, ) - return helptext def create_help_dict(self, section="commands"): - self.helpDict = {} - for command in sorted(self.env["commands"][section].keys()): - self.helpDict[len(self.helpDict)] = self.get_command_help_text( - command, section - ) - if len(self.helpDict) > 0: - self.tutorialListIndex = 0 - else: - self.tutorialListIndex = None + shortcuts_by_command = self._get_shortcuts_by_command() + self.help_lists = { + self.ACTIVE_SECTION: [], + self.UNBOUND_SECTION: [], + self.PLUGINS_SECTION: [], + } + for command in sorted(self.env["commands"][section]): + command_instance = self.env["commands"][section][command] + if not getattr(command_instance, "help_visible", True): + continue + shortcuts = shortcuts_by_command.get(command, []) + help_text = self.get_command_help_text(command, shortcuts) + if any("KEY_SCRIPT" in shortcut[1] for shortcut in shortcuts): + target_section = self.PLUGINS_SECTION + elif shortcuts: + target_section = self.ACTIVE_SECTION + else: + target_section = self.UNBOUND_SECTION + self.help_lists[target_section].append(help_text) + self.help_section = self.ACTIVE_SECTION + self.tutorial_list_index = None + + def get_help_section_name(self): + if self.help_section == self.PLUGINS_SECTION: + return _("Plugins") + if self.help_section == self.UNBOUND_SECTION: + return _("Unbound actions") + return _("Active actions") + + def select_help_section(self, section): + if section not in self.help_lists: + return self.get_help_section_name() + self.help_section = section + self.tutorial_list_index = None + return self.get_help_section_name() + + def next_help_section(self): + section_index = self.HELP_SECTIONS.index(self.help_section) + section_index = (section_index + 1) % len(self.HELP_SECTIONS) + return self.select_help_section(self.HELP_SECTIONS[section_index]) + + def prev_help_section(self): + section_index = self.HELP_SECTIONS.index(self.help_section) + section_index = (section_index - 1) % len(self.HELP_SECTIONS) + return self.select_help_section(self.HELP_SECTIONS[section_index]) def get_help_for_current_index(self): - if self.tutorialListIndex is None: - return "" - return self.helpDict[self.tutorialListIndex] + entries = self.help_lists[self.help_section] + if self.tutorial_list_index is None or not entries: + return self.get_help_section_name() + return entries[self.tutorial_list_index] def next_index(self): - if self.tutorialListIndex is None: + entries = self.help_lists[self.help_section] + if not entries: + self.tutorial_list_index = None return - self.tutorialListIndex += 1 - if self.tutorialListIndex >= len(self.helpDict): - self.tutorialListIndex = 0 + if self.tutorial_list_index is None: + self.tutorial_list_index = 0 + return + self.tutorial_list_index = (self.tutorial_list_index + 1) % len( + entries + ) def prev_index(self): - if self.tutorialListIndex is None: + entries = self.help_lists[self.help_section] + if not entries: + self.tutorial_list_index = None return - self.tutorialListIndex -= 1 - if self.tutorialListIndex < 0: - self.tutorialListIndex = len(self.helpDict) - 1 + if self.tutorial_list_index is None: + self.tutorial_list_index = len(entries) - 1 + return + self.tutorial_list_index = (self.tutorial_list_index - 1) % len( + entries + ) diff --git a/src/fenrirscreenreader/core/inputDriver.py b/src/fenrirscreenreader/core/inputDriver.py index b45d3352..979a78d2 100644 --- a/src/fenrirscreenreader/core/inputDriver.py +++ b/src/fenrirscreenreader/core/inputDriver.py @@ -54,6 +54,9 @@ class InputDriver: return True return True + def set_help_capture(self, enabled): + return True + def force_ungrab(self): """Emergency method to release grabbed devices in case of failure""" if not self._initialized: diff --git a/src/fenrirscreenreader/core/inputManager.py b/src/fenrirscreenreader/core/inputManager.py index 8893659f..29680e0c 100644 --- a/src/fenrirscreenreader/core/inputManager.py +++ b/src/fenrirscreenreader/core/inputManager.py @@ -277,6 +277,18 @@ class InputManager: return False return True + def set_help_capture(self, enabled): + try: + return self.env["runtime"]["InputDriver"].set_help_capture( + enabled + ) + except Exception as error: + self.env["runtime"]["DebugManager"].write_debug_out( + "InputManager could not change help capture: " + str(error), + debug.DebugLevel.ERROR, + ) + return not enabled + def handle_plug_input_device(self, event_data): for deviceEntry in event_data: self.update_input_devices(deviceEntry["device"]) @@ -576,6 +588,9 @@ class InputManager: self.env["bindings"][ str([1, ["KEY_F1", "KEY_FENRIR"]]) ] = "TOGGLE_TUTORIAL_MODE" + self.env["rawBindings"][ + str([1, ["KEY_F1", "KEY_FENRIR"]]) + ] = [1, ["KEY_F1", "KEY_FENRIR"]] def is_valid_key(self, key): return key in inputData.key_names diff --git a/src/fenrirscreenreader/inputDriver/x11Driver.py b/src/fenrirscreenreader/inputDriver/x11Driver.py index bb8742ec..33948ce0 100644 --- a/src/fenrirscreenreader/inputDriver/x11Driver.py +++ b/src/fenrirscreenreader/inputDriver/x11Driver.py @@ -61,20 +61,31 @@ class driver(inputDriver): "Escape": "KEY_ESC", "space": "KEY_SPACE", "minus": "KEY_MINUS", + "-": "KEY_MINUS", "underscore": "KEY_MINUS", "_": "KEY_MINUS", "equal": "KEY_EQUAL", + "=": "KEY_EQUAL", "plus": "KEY_EQUAL", "+": "KEY_EQUAL", "bracketleft": "KEY_LEFTBRACE", + "[": "KEY_LEFTBRACE", "bracketright": "KEY_RIGHTBRACE", + "]": "KEY_RIGHTBRACE", "backslash": "KEY_BACKSLASH", + "\\": "KEY_BACKSLASH", "semicolon": "KEY_SEMICOLON", + ";": "KEY_SEMICOLON", "apostrophe": "KEY_APOSTROPHE", + "'": "KEY_APOSTROPHE", "grave": "KEY_GRAVE", + "`": "KEY_GRAVE", "comma": "KEY_COMMA", + ",": "KEY_COMMA", "period": "KEY_DOT", + ".": "KEY_DOT", "slash": "KEY_SLASH", + "/": "KEY_SLASH", "Shift_L": "KEY_LEFTSHIFT", "Shift_R": "KEY_RIGHTSHIFT", "Control_L": "KEY_LEFTCTRL", @@ -173,6 +184,7 @@ class driver(inputDriver): self.command_key_active = False self.chord_modifiers = set() self.chord_keys = set() + self.help_keyboard_grabbed = False def initialize(self, environment): self.env = environment @@ -214,6 +226,7 @@ class driver(inputDriver): self._initialized = True def shutdown(self): + self.set_help_capture(False) self.ungrab_all_devices() try: if self.display: @@ -861,6 +874,54 @@ class driver(inputDriver): pass return True + def set_help_capture(self, enabled): + if not self._initialized or not self.display or not self.window: + return not enabled + if enabled: + if self.help_keyboard_grabbed: + return True + try: + grab_status = self.window.grab_keyboard( + False, + X.GrabModeAsync, + X.GrabModeAsync, + X.CurrentTime, + ) + self.display.flush() + self.help_keyboard_grabbed = grab_status == X.GrabSuccess + if not self.help_keyboard_grabbed: + self.write_debug( + "x11Driver help keyboard grab failed with status " + + str(grab_status), + debug.DebugLevel.WARNING, + ) + return self.help_keyboard_grabbed + except Exception as error: + self.help_keyboard_grabbed = False + self.write_debug( + "x11Driver help keyboard grab failed: " + str(error), + debug.DebugLevel.ERROR, + ) + return False + if self.help_keyboard_grabbed: + for attempt in range(1, 4): + try: + self.display.ungrab_keyboard(X.CurrentTime) + self.display.sync() + break + except Exception as error: + self.write_debug( + "x11Driver help keyboard ungrab attempt " + + str(attempt) + + " failed: " + + str(error), + debug.DebugLevel.ERROR, + ) + else: + return False + self.help_keyboard_grabbed = False + return True + def remove_all_devices(self): self.ungrab_all_devices() diff --git a/tests/unit/test_fenrir_manager_shortcuts.py b/tests/unit/test_fenrir_manager_shortcuts.py index e61a3c1b..545c1c35 100644 --- a/tests/unit/test_fenrir_manager_shortcuts.py +++ b/tests/unit/test_fenrir_manager_shortcuts.py @@ -101,6 +101,98 @@ def test_bare_key_taps_advance_shortcut_repeat(monkeypatch): assert manager.env["input"]["shortcut_repeat"] == 3 +@pytest.mark.unit +def test_tutorial_toggle_runs_while_tutorial_mode_is_active(): + manager = FenrirManager.__new__(FenrirManager) + command_manager = Mock() + command_manager.command_exists.return_value = False + manager.environment = { + "runtime": { + "HelpManager": Mock( + is_tutorial_mode=Mock(return_value=True) + ), + "CommandManager": command_manager, + "ReadAllManager": Mock(is_active=Mock(return_value=False)), + "VmenuManager": Mock(get_active=Mock(return_value=False)), + "SpeechHistoryManager": Mock(is_active=Mock(return_value=False)), + } + } + + manager.handle_execute_command({"data": "TOGGLE_TUTORIAL_MODE"}) + + command_manager.run_command.assert_called_once_with( + "TOGGLE_TUTORIAL_MODE", "commands" + ) + command_manager.execute_command.assert_not_called() + + +@pytest.mark.unit +def test_tutorial_mode_swallows_input_on_ignored_screen(): + manager, input_manager = create_handle_input_manager( + no_key_pressed=False + ) + manager.environment["runtime"]["HelpManager"].is_tutorial_mode.return_value = ( + True + ) + manager.environment["runtime"]["ScreenManager"].is_ignored_screen.return_value = ( + True + ) + + manager.handle_input( + {"data": {"event_name": "KEY_A", "event_state": 1}} + ) + + input_manager.clear_event_buffer.assert_called() + input_manager.write_event_buffer.assert_not_called() + input_manager.key_echo.assert_called_once_with( + {"event_name": "KEY_A", "event_state": 1} + ) + manager.detect_shortcut_command.assert_called_once_with() + + +@pytest.mark.unit +def test_tutorial_mode_promotes_non_fenrir_chord_to_command(): + manager = FenrirManager.__new__(FenrirManager) + manager.modifierInput = False + manager.modifier_prefix_input = False + manager.singleKeyCommand = False + manager.command = "" + input_manager = Mock( + is_key_press=Mock(return_value=False), + curr_input_has_command_modifier=Mock(return_value=False), + curr_input_is_modifier_prefix=Mock(return_value=False), + no_key_pressed=Mock(return_value=False), + get_curr_shortcut=Mock( + return_value=str([1, ["KEY_CTRL", "KEY_S"]]) + ), + get_command_for_shortcut=Mock(return_value="SPELL_CHECK"), + ) + event_manager = Mock() + manager.environment = { + "input": { + "key_forward": 0, + "prev_input": ["KEY_CTRL"], + "curr_input": ["KEY_CTRL", "KEY_S"], + }, + "runtime": { + "InputManager": input_manager, + "EventManager": event_manager, + "HelpManager": Mock( + is_tutorial_mode=Mock(return_value=True) + ), + "VmenuManager": Mock(get_active=Mock(return_value=False)), + "DiffReviewManager": Mock(is_active=Mock(return_value=False)), + "SpeechHistoryManager": Mock(is_active=Mock(return_value=False)), + }, + } + + manager.detect_shortcut_command() + + event_manager.put_to_event_queue.assert_called_once_with( + FenrirEventType.execute_command, "SPELL_CHECK" + ) + + @pytest.mark.unit def test_bare_key_taps_resolve_progressive_shortcuts(monkeypatch): manager = create_input_manager() diff --git a/tests/unit/test_help_manager.py b/tests/unit/test_help_manager.py new file mode 100644 index 00000000..1f3d9ff0 --- /dev/null +++ b/tests/unit/test_help_manager.py @@ -0,0 +1,202 @@ +import ast +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.core.commandManager import CommandManager +from fenrirscreenreader.core.helpManager import HelpManager + + +def create_help_manager(capture_succeeds=True): + manager = HelpManager() + bindings = { + str([1, ["KEY_F1", "KEY_FENRIR"]]): "TOGGLE_TUTORIAL_MODE", + str([1, ["KEY_FENRIR", "KEY_S"]]): "SPELL_CHECK", + str([1, ["KEY_FENRIR", "KEY_W"]]): "READ_WORD", + str([2, ["KEY_FENRIR", "KEY_W"]]): "READ_WORD", + str([1, ["KEY_SCRIPT", "KEY_U"]]): ( + "MUSICPLAYER__-__KEY_U" + ), + } + raw_bindings = { + shortcut: ast.literal_eval(shortcut) for shortcut in bindings + } + command_manager = Mock() + descriptions = { + "SPELL_CHECK": "spell check", + "READ_WORD": "read current word", + "UNBOUND_ACTION": "an action without a shortcut", + "MUSICPLAYER__-__KEY_U": "music player plugin", + } + command_manager.get_command_description.side_effect = ( + lambda command, section="commands": descriptions.get(command, "") + ) + input_manager = Mock() + input_manager.set_help_capture.return_value = capture_succeeds + internal_command = Mock() + internal_command.help_visible = False + plugin_command = Mock() + plugin_command.help_category = "plugins" + manager.initialize( + { + "general": {"tutorialMode": False}, + "bindings": bindings.copy(), + "rawBindings": { + shortcut: [raw[0], raw[1].copy()] + for shortcut, raw in raw_bindings.items() + }, + "commands": { + "commands": { + "READ_WORD": Mock(), + "SPELL_CHECK": Mock(), + "TOGGLE_TUTORIAL_MODE": Mock(), + "UNBOUND_ACTION": Mock(), + "00_INIT_COMMANDS": internal_command, + "MUSICPLAYER__-__KEY_U": plugin_command, + } + }, + "runtime": { + "CommandManager": command_manager, + "DebugManager": Mock(), + "InputDriver": Mock(refresh_grabs=Mock()), + "InputManager": input_manager, + "VmenuManager": Mock(get_active=Mock(return_value=False)), + }, + } + ) + return manager, bindings, raw_bindings + + +@pytest.mark.unit +def test_help_lists_bound_actions_once_with_all_shortcuts(): + manager, _bindings, raw_bindings = create_help_manager() + + manager.set_tutorial_mode(True) + + active_text = " ".join(manager.help_lists["active"]) + assert active_text.count("read word") == 1 + assert "fenrir, w" in active_text + assert "2 times fenrir, w" in active_text + assert "unbound action" not in active_text + assert "musicplayer" not in active_text + assert "00 init commands" not in active_text + assert all( + "00 init commands" not in entry + for entry in manager.help_lists["unbound"] + ) + assert manager.help_lists["unbound"] == [ + "unbound action, Shortcuts unbound, Description an action without a shortcut" + ] + assert manager.help_lists["plugins"] == [ + "musicplayer, Shortcuts script, u, Description music player plugin" + ] + assert raw_bindings[str([1, ["KEY_FENRIR", "KEY_S"]])] == [ + 1, + ["KEY_FENRIR", "KEY_S"], + ] + + +@pytest.mark.unit +def test_help_navigation_defaults_to_active_and_starts_before_first_item(): + manager, _bindings, _raw_bindings = create_help_manager() + manager.set_tutorial_mode(True) + + assert manager.get_help_section_name() == "Active actions" + assert manager.get_help_for_current_index() == "Active actions" + + manager.next_index() + assert manager.get_help_for_current_index() == manager.help_lists["active"][0] + + assert manager.select_help_section("unbound") == "Unbound actions" + assert manager.get_help_for_current_index() == "Unbound actions" + manager.prev_index() + assert manager.get_help_for_current_index() == manager.help_lists["unbound"][-1] + + assert manager.next_help_section() == "Plugins" + assert manager.next_help_section() == "Active actions" + assert manager.prev_help_section() == "Plugins" + + +@pytest.mark.unit +def test_help_installs_and_restores_modal_bindings_exactly(): + manager, bindings, raw_bindings = create_help_manager() + + manager.set_tutorial_mode(True) + + assert ( + manager.env["bindings"][str([1, ["KEY_LEFT"]])] + == "PREV_HELP_SECTION" + ) + assert ( + manager.env["bindings"][str([1, ["KEY_RIGHT"]])] + == "NEXT_HELP_SECTION" + ) + assert manager.env["bindings"][str([1, ["KEY_ESC"]])] == "TOGGLE_TUTORIAL_MODE" + assert manager.env["rawBindings"][str([1, ["KEY_RIGHT"]])] == [ + 1, + ["KEY_RIGHT"], + ] + manager.env["runtime"]["InputManager"].set_help_capture.assert_called_once_with( + True + ) + + manager.set_tutorial_mode(False) + + assert manager.env["bindings"] == bindings + assert manager.env["rawBindings"] == raw_bindings + manager.env["runtime"]["InputManager"].set_help_capture.assert_called_with( + False + ) + + +@pytest.mark.unit +def test_help_records_degraded_capture_without_refusing_mode(): + manager, _bindings, _raw_bindings = create_help_manager( + capture_succeeds=False + ) + + manager.set_tutorial_mode(True) + + assert manager.is_tutorial_mode() is True + assert manager.is_capture_degraded() is True + + +@pytest.mark.unit +def test_help_does_not_claim_exit_when_capture_release_fails(): + manager, bindings, raw_bindings = create_help_manager() + manager.set_tutorial_mode(True) + manager.env["runtime"]["InputManager"].set_help_capture.return_value = False + + assert manager.set_tutorial_mode(False) is False + + assert manager.is_tutorial_mode() is True + assert manager.env["bindings"] != bindings + assert manager.env["rawBindings"] != raw_bindings + + +@pytest.mark.unit +def test_command_description_is_presented_on_ignored_screen_in_help(): + command_manager = CommandManager() + output_manager = Mock() + command_manager.env = { + "runtime": { + "ScreenManager": Mock( + is_ignored_screen=Mock(return_value=True) + ), + "HelpManager": Mock( + is_tutorial_mode=Mock(return_value=True) + ), + "DebugManager": Mock(), + "OutputManager": output_manager, + } + } + command_manager.command_exists = Mock(return_value=True) + command_manager.get_command_description = Mock( + return_value="spell check" + ) + + command_manager.execute_command("SPELL_CHECK", "commands") + + output_manager.present_text.assert_called_once_with( + "spell check", interrupt=False + ) diff --git a/tests/unit/test_review_spelling_commands.py b/tests/unit/test_review_spelling_commands.py index 3a18d233..67faeaf3 100644 --- a/tests/unit/test_review_spelling_commands.py +++ b/tests/unit/test_review_spelling_commands.py @@ -126,6 +126,14 @@ def test_progressive_review_bindings(layout, character_keys, word_keys): assert bindings[str([2, word_keys])] == "REVIEW_CURR_WORD_SPELL" assert bindings[str([3, word_keys])] == "REVIEW_CURR_WORD_PHONETIC" + +@pytest.mark.unit +@pytest.mark.parametrize("layout", ["desktop", "laptop"]) +def test_layouts_do_not_bind_removed_keyboard_layout_cycle(layout): + bindings = load_bindings(layout) + + assert "CYCLE_KEYBOARD_LAYOUT" not in bindings.values() + removed_commands = { "REVIEW_PREV_CHAR_PHONETIC", "REVIEW_NEXT_CHAR_PHONETIC", diff --git a/tests/unit/test_toggle_vmenu_command.py b/tests/unit/test_toggle_vmenu_command.py new file mode 100644 index 00000000..baa536e1 --- /dev/null +++ b/tests/unit/test_toggle_vmenu_command.py @@ -0,0 +1,34 @@ +import importlib.util +from pathlib import Path +from unittest.mock import Mock + +import pytest + + +COMMAND_PATH = ( + Path(__file__).resolve().parents[2] + / "src" + / "fenrirscreenreader" + / "commands" + / "commands" + / "toggle_vmenu_mode.py" +) + + +def load_command(): + spec = importlib.util.spec_from_file_location( + "fenrir_toggle_vmenu_mode", COMMAND_PATH + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.command() + + +@pytest.mark.unit +def test_get_description_does_not_toggle_vmenu_mode(): + vmenu_manager = Mock() + command = load_command() + command.initialize({"runtime": {"VmenuManager": vmenu_manager}}) + + assert command.get_description() == "Entering or Leaving v menu mode." + vmenu_manager.toggle_vmenu_mode.assert_not_called() diff --git a/tests/unit/test_x11_terminal_mode.py b/tests/unit/test_x11_terminal_mode.py index 08055a34..2ee6859a 100644 --- a/tests/unit/test_x11_terminal_mode.py +++ b/tests/unit/test_x11_terminal_mode.py @@ -189,6 +189,29 @@ def test_x11_should_emit_unbound_mapped_keys_for_speech_interrupt(): assert x11.should_emit_key("KEY_ENTER") is True +@pytest.mark.unit +@pytest.mark.parametrize( + ("keysym_name", "key_name"), + [ + ("-", "KEY_MINUS"), + ("=", "KEY_EQUAL"), + ("[", "KEY_LEFTBRACE"), + ("]", "KEY_RIGHTBRACE"), + ("\\", "KEY_BACKSLASH"), + (";", "KEY_SEMICOLON"), + ("'", "KEY_APOSTROPHE"), + ("`", "KEY_GRAVE"), + (",", "KEY_COMMA"), + (".", "KEY_DOT"), + ("/", "KEY_SLASH"), + ], +) +def test_x11_maps_literal_punctuation_keysyms(keysym_name, key_name): + x11 = X11Driver() + + assert x11.keysym_name_to_key_name(keysym_name) == key_name + + @pytest.mark.unit def test_x11_build_passive_grabs_for_fenrir_keys_and_shortcuts(): x11 = X11Driver() @@ -372,6 +395,58 @@ def test_x11_write_event_buffer_does_not_replay_key_release(): assert x11.env["input"]["event_buffer"] == [] +@pytest.mark.unit +def test_x11_help_capture_grabs_and_releases_keyboard(): + x11 = X11Driver() + x11._initialized = True + x11.window = Mock() + x11.window.grab_keyboard.return_value = X.GrabSuccess + x11.display = Mock() + x11.env = {"runtime": {"DebugManager": Mock()}} + + assert x11.set_help_capture(True) is True + x11.window.grab_keyboard.assert_called_once_with( + False, + X.GrabModeAsync, + X.GrabModeAsync, + X.CurrentTime, + ) + assert x11.help_keyboard_grabbed is True + + assert x11.set_help_capture(False) is True + x11.display.ungrab_keyboard.assert_called_once_with(X.CurrentTime) + x11.display.sync.assert_called_once_with() + assert x11.help_keyboard_grabbed is False + + +@pytest.mark.unit +def test_x11_help_capture_reports_failed_grab(): + x11 = X11Driver() + x11._initialized = True + x11.window = Mock() + x11.window.grab_keyboard.return_value = X.AlreadyGrabbed + x11.display = Mock() + x11.env = {"runtime": {"DebugManager": Mock()}} + + assert x11.set_help_capture(True) is False + assert x11.help_keyboard_grabbed is False + + +@pytest.mark.unit +def test_x11_help_capture_retries_failed_keyboard_release(): + x11 = X11Driver() + x11._initialized = True + x11.help_keyboard_grabbed = True + x11.window = Mock() + x11.display = Mock() + x11.display.sync.side_effect = RuntimeError("ungrab failed") + x11.env = {"runtime": {"DebugManager": Mock()}} + + assert x11.set_help_capture(False) is False + assert x11.display.ungrab_keyboard.call_count == 3 + assert x11.help_keyboard_grabbed is True + + @pytest.mark.unit def test_x11_map_event_keeps_x_event_time_for_replay(): x11 = X11Driver() diff --git a/tools/fenrir.pot b/tools/fenrir.pot index 14ece9d0..cfa57eaa 100644 --- a/tools/fenrir.pot +++ b/tools/fenrir.pot @@ -882,12 +882,24 @@ msgstr "" msgid "speech enabled" msgstr "" -#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:18 -msgid "Exiting tutorial mode. To enter tutorial mode again press Fenrir+f1" +#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:23 +msgid "enter or leave tutorial mode" msgstr "" -#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:22 -msgid "Entering tutorial mode. In this mode commands are described but not executed. You can move through the list of commands with the up and down arrow keys. To Exit tutorial mode press Fenrir+f1." +#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:28 +msgid "Entering tutorial mode. In this mode commands are described but not executed. Use up and down to browse actions and left and right to switch between Active actions, Unbound actions, and Plugins. Press Space to repeat the current item. To exit tutorial mode press Fenrir+F1 or Escape. Active actions." +msgstr "" + +#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:32 +msgid "Unable to exit tutorial mode because exclusive keyboard capture could not be released. Press Fenrir+F1 or Escape to try again." +msgstr "" + +#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:38 +msgid "Warning: full keyboard capture is unavailable. Unbound keys may reach the active application." +msgstr "" + +#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:46 +msgid "Exiting tutorial mode. To enter tutorial mode again press Fenrir+F1" msgstr "" #: ../src/fenrirscreenreader\commands\commands\toggle_vmenu_mode.py:18 @@ -906,6 +918,10 @@ msgstr "" msgid "get current help message" msgstr "" +#: ../src/fenrirscreenreader\commands\help\next_help_section.py:21 +msgid "show next help category" +msgstr "" + #: ../src/fenrirscreenreader\commands\help\next_help.py:17 msgid "get next help message" msgstr "" @@ -914,6 +930,10 @@ msgstr "" msgid "get prev help message" msgstr "" +#: ../src/fenrirscreenreader\commands\help\prev_help_section.py:21 +msgid "show previous help category" +msgstr "" + #: #: ../src/fenrirscreenreader\commands\onCursorChange\65000-present_line_if_cursor_change_vertical.py:46 msgid "indented " @@ -1076,10 +1096,38 @@ msgstr "" msgid "Quit Fenrir" msgstr "" -#: ../src/fenrirscreenreader\core\helpManager.py:77 +#: ../src/fenrirscreenreader\core\helpManager.py:132 +msgid "{} times " +msgstr "" + +#: ../src/fenrirscreenreader\core\helpManager.py:161 msgid "toggles the tutorial mode" msgstr "" +#: ../src/fenrirscreenreader\core\helpManager.py:168 +msgid "no description available" +msgstr "" + +#: ../src/fenrirscreenreader\core\helpManager.py:175 +msgid "unbound" +msgstr "" + +#: ../src/fenrirscreenreader\core\helpManager.py:176 +msgid "{command_name}, Shortcuts {command_shortcuts}, Description {command_description}" +msgstr "" + +#: ../src/fenrirscreenreader\core\helpManager.py:203 +msgid "Plugins" +msgstr "" + +#: ../src/fenrirscreenreader\core\helpManager.py:205 +msgid "Unbound actions" +msgstr "" + +#: ../src/fenrirscreenreader\core\helpManager.py:206 +msgid "Active actions" +msgstr "" + #: ../src/fenrirscreenreader\core\outputManager.py:297 msgid "speech temporary disabled" msgstr ""