Improve tutorial help navigation

This commit is contained in:
Storm Dragon
2026-08-13 09:29:43 -04:00
parent e7f69b2159
commit 4ed431640e
20 changed files with 910 additions and 234 deletions
-1
View File
@@ -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_DOWN=read_all_by_line
KEY_FENRIR,KEY_CTRL,KEY_PAGEDOWN=read_all_by_page KEY_FENRIR,KEY_CTRL,KEY_PAGEDOWN=read_all_by_page
KEY_FENRIR,KEY_SHIFT,KEY_V=announce_fenrir_version KEY_FENRIR,KEY_SHIFT,KEY_V=announce_fenrir_version
KEY_FENRIR,KEY_LEFTCTRL,KEY_F4=cycle_keyboard_layout
-1
View File
@@ -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_DOWN=read_all_by_line
KEY_FENRIR,KEY_CTRL,KEY_PAGEDOWN=read_all_by_page KEY_FENRIR,KEY_CTRL,KEY_PAGEDOWN=read_all_by_page
KEY_FENRIR,KEY_SHIFT,KEY_V=announce_fenrir_version KEY_FENRIR,KEY_SHIFT,KEY_V=announce_fenrir_version
KEY_FENRIR,KEY_LEFTCTRL,KEY_F4=cycle_keyboard_layout
@@ -13,6 +13,8 @@ from fenrirscreenreader.core.i18n import _
class command: class command:
help_visible = False
def __init__(self): def __init__(self):
pass pass
@@ -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
@@ -19,19 +19,47 @@ class command:
pass pass
def get_description(self): def get_description(self):
self.env["runtime"]["HelpManager"].toggle_tutorial_mode() return _("enter or leave tutorial mode")
return _(
"Exiting tutorial mode. To enter tutorial mode again press Fenrir+f1"
)
def run(self): def run(self):
self.env["runtime"]["HelpManager"].toggle_tutorial_mode() help_manager = self.env["runtime"]["HelpManager"]
if self.env["runtime"]["HelpManager"].is_tutorial_mode(): 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( self.env["runtime"]["OutputManager"].present_text(
_( _(
"Entering tutorial mode. In this mode commands are described but not " "Unable to exit tutorial mode because exclusive keyboard "
"executed. You can move through the list of commands with the up and " "capture could not be released. Press Fenrir+F1 or Escape "
"down arrow keys. To Exit tutorial mode press Fenrir+f1." "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, interrupt=True,
) )
@@ -19,7 +19,6 @@ class command:
pass pass
def get_description(self): def get_description(self):
self.env["runtime"]["VmenuManager"].toggle_vmenu_mode()
return _("Entering or Leaving v menu mode.") return _("Entering or Leaving v menu mode.")
def run(self): def run(self):
@@ -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
@@ -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
@@ -436,7 +436,10 @@ class CommandManager:
) )
def execute_command(self, command, section="commands"): 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 return
if self.command_exists(command, section): if self.command_exists(command, section):
try: try:
+24 -4
View File
@@ -82,13 +82,22 @@ class FenrirManager:
else: else:
return 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() self.environment["runtime"]["InputManager"].write_event_buffer()
else: else:
if self.environment["runtime"]["HelpManager"].is_tutorial_mode(): if tutorial_mode:
self.environment["runtime"][ self.environment["runtime"][
"InputManager" "InputManager"
].clear_event_buffer() ].clear_event_buffer()
if self.environment["runtime"][
"ScreenManager"
].is_ignored_screen():
self.environment["runtime"]["InputManager"].key_echo( self.environment["runtime"]["InputManager"].key_echo(
event["data"] event["data"]
) )
@@ -152,10 +161,15 @@ class FenrirManager:
if self.environment["runtime"]["CommandManager"].command_exists( if self.environment["runtime"]["CommandManager"].command_exists(
current_command, "help" current_command, "help"
): ):
self.environment["runtime"]["CommandManager"].execute_command( self.environment["runtime"]["CommandManager"].run_command(
current_command, "help" current_command, "help"
) )
return return
if current_command == "TOGGLE_TUTORIAL_MODE":
self.environment["runtime"]["CommandManager"].run_command(
current_command, "commands"
)
return
elif self.environment["runtime"]["VmenuManager"].get_active(): elif self.environment["runtime"]["VmenuManager"].get_active():
if self.environment["runtime"]["CommandManager"].command_exists( if self.environment["runtime"]["CommandManager"].command_exists(
current_command, "vmenu-navigation" current_command, "vmenu-navigation"
@@ -324,7 +338,13 @@ class FenrirManager:
self.singleKeyCommand = True self.singleKeyCommand = True
elif ( 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"][ or self.environment["runtime"][
"DiffReviewManager" "DiffReviewManager"
].is_active() ].is_active()
+225 -92
View File
@@ -4,65 +4,154 @@
# Fenrir TTY screen reader # Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors. # By Chrys, Storm Dragon, and contributors.
import ast
import copy
from fenrirscreenreader.core import debug from fenrirscreenreader.core import debug
from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.core.i18n import _
class HelpManager: class HelpManager:
ACTIVE_SECTION = "active"
UNBOUND_SECTION = "unbound"
PLUGINS_SECTION = "plugins"
HELP_SECTIONS = (
ACTIVE_SECTION,
UNBOUND_SECTION,
PLUGINS_SECTION,
)
def __init__(self): def __init__(self):
self.helpDict = {} self.env = None
self.tutorialListIndex = 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): def initialize(self, environment):
self.env = environment self.env = environment
def shutdown(self): def shutdown(self):
pass if self.is_tutorial_mode():
self.set_tutorial_mode(False)
def toggle_tutorial_mode(self): 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): def set_tutorial_mode(self, tutorial_mode):
if self.env["runtime"]["VmenuManager"].get_active(): if tutorial_mode == self.is_tutorial_mode():
return return True
self.env["general"]["tutorialMode"] = newTutorialMode if tutorial_mode and self.env["runtime"]["VmenuManager"].get_active():
if newTutorialMode: 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.create_help_dict()
self.env["bindings"][ self.env["general"]["tutorialMode"] = True
str([1, ["KEY_ESC"]]) self._install_help_bindings()
] = "TOGGLE_TUTORIAL_MODE" self._refresh_input_bindings()
self.env["bindings"][str([1, ["KEY_UP"]])] = "PREV_HELP" self.capture_degraded = not self.env["runtime"][
self.env["bindings"][str([1, ["KEY_DOWN"]])] = "NEXT_HELP" "InputManager"
self.env["bindings"][str([1, ["KEY_SPACE"]])] = "CURR_HELP" ].set_help_capture(True)
else: return True
try:
self.env["bindings"] = self.env["runtime"][ if not self.env["runtime"]["InputManager"].set_help_capture(False):
"SettingsManager"
].get_binding_backup()
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out( self.env["runtime"]["DebugManager"].write_debug_out(
"HelpManager set_tutorial_mode: Error restoring binding backup: " "HelpManager could not release exclusive input capture",
+ str(e), 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, debug.DebugLevel.ERROR,
) )
def is_tutorial_mode(self): def is_tutorial_mode(self):
if self.env is None:
return False
return self.env["general"]["tutorialMode"] return self.env["general"]["tutorialMode"]
def get_formatted_shortcut_for_command(self, command): def is_capture_degraded(self):
shortcut = [] return self.capture_degraded
raw_shortcut = []
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: try:
raw_shortcut = list(self.env["bindings"].keys())[ raw_shortcut = ast.literal_eval(shortcut_key)
list(self.env["bindings"].values()).index(command) except (SyntaxError, ValueError):
] continue
raw_shortcut = self.env["rawBindings"][raw_shortcut] shortcuts_by_command.setdefault(command, []).append(
# prefer numbers for multitap copy.deepcopy(raw_shortcut)
if raw_shortcut[0] in range(2, 9): )
formatted_key = str(raw_shortcut[0]) + " times " return shortcuts_by_command
shortcut.append(formatted_key)
# prefer metha keys def _format_shortcut(self, raw_shortcut):
for k in [ 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_FENRIR",
"KEY_SCRIPT", "KEY_SCRIPT",
"KEY_CTRL", "KEY_CTRL",
@@ -70,33 +159,26 @@ class HelpManager:
"KEY_ALT", "KEY_ALT",
"KEY_META", "KEY_META",
]: ]:
if k in raw_shortcut[1]: if key_name in keys:
formatted_key = k formatted_keys.append(self._format_key_name(key_name))
formatted_key = formatted_key.lower() keys.remove(key_name)
formatted_key = formatted_key.replace("key_kp", " keypad ") formatted_keys.extend(self._format_key_name(key) for key in keys)
formatted_key = formatted_key.replace("key_", " ") return repeat_prefix + ", ".join(formatted_keys)
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 get_command_help_text(self, command, section="commands"): def _format_key_name(self, key_name):
command_name = command.lower() formatted_key = key_name.lower()
command_name = command_name.split("__-__")[0] formatted_key = formatted_key.replace("key_kp", "keypad ")
command_name = command_name.replace("_", " ") formatted_key = formatted_key.replace("key_", "")
command_name = command_name.replace("_", " ") 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": if command == "TOGGLE_TUTORIAL_MODE":
command_description = _("toggles the tutorial mode") command_description = _("toggles the tutorial mode")
else: else:
@@ -104,45 +186,96 @@ class HelpManager:
"CommandManager" "CommandManager"
].get_command_description(command, section="commands") ].get_command_description(command, section="commands")
if command_description == "": if command_description == "":
command_description = "no Description available" command_description = _("no description available")
command_shortcut = self.get_formatted_shortcut_for_command(command) if shortcuts is None:
if command_shortcut == "": shortcuts = self._get_shortcuts_by_command().get(command, [])
command_shortcut = "unbound" command_shortcuts = "; ".join(
helptext = ( self._format_shortcut(shortcut) for shortcut in shortcuts
command_name )
+ ", Shortcut " if command_shortcuts == "":
+ command_shortcut command_shortcuts = _("unbound")
+ ", Description " return _(
+ command_description "{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"): def create_help_dict(self, section="commands"):
self.helpDict = {} shortcuts_by_command = self._get_shortcuts_by_command()
for command in sorted(self.env["commands"][section].keys()): self.help_lists = {
self.helpDict[len(self.helpDict)] = self.get_command_help_text( self.ACTIVE_SECTION: [],
command, section self.UNBOUND_SECTION: [],
) self.PLUGINS_SECTION: [],
if len(self.helpDict) > 0: }
self.tutorialListIndex = 0 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: else:
self.tutorialListIndex = None 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): def get_help_for_current_index(self):
if self.tutorialListIndex is None: entries = self.help_lists[self.help_section]
return "" if self.tutorial_list_index is None or not entries:
return self.helpDict[self.tutorialListIndex] return self.get_help_section_name()
return entries[self.tutorial_list_index]
def next_index(self): 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 return
self.tutorialListIndex += 1 if self.tutorial_list_index is None:
if self.tutorialListIndex >= len(self.helpDict): self.tutorial_list_index = 0
self.tutorialListIndex = 0 return
self.tutorial_list_index = (self.tutorial_list_index + 1) % len(
entries
)
def prev_index(self): 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 return
self.tutorialListIndex -= 1 if self.tutorial_list_index is None:
if self.tutorialListIndex < 0: self.tutorial_list_index = len(entries) - 1
self.tutorialListIndex = len(self.helpDict) - 1 return
self.tutorial_list_index = (self.tutorial_list_index - 1) % len(
entries
)
@@ -54,6 +54,9 @@ class InputDriver:
return True return True
return True return True
def set_help_capture(self, enabled):
return True
def force_ungrab(self): def force_ungrab(self):
"""Emergency method to release grabbed devices in case of failure""" """Emergency method to release grabbed devices in case of failure"""
if not self._initialized: if not self._initialized:
@@ -277,6 +277,18 @@ class InputManager:
return False return False
return True 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): def handle_plug_input_device(self, event_data):
for deviceEntry in event_data: for deviceEntry in event_data:
self.update_input_devices(deviceEntry["device"]) self.update_input_devices(deviceEntry["device"])
@@ -576,6 +588,9 @@ class InputManager:
self.env["bindings"][ self.env["bindings"][
str([1, ["KEY_F1", "KEY_FENRIR"]]) str([1, ["KEY_F1", "KEY_FENRIR"]])
] = "TOGGLE_TUTORIAL_MODE" ] = "TOGGLE_TUTORIAL_MODE"
self.env["rawBindings"][
str([1, ["KEY_F1", "KEY_FENRIR"]])
] = [1, ["KEY_F1", "KEY_FENRIR"]]
def is_valid_key(self, key): def is_valid_key(self, key):
return key in inputData.key_names return key in inputData.key_names
@@ -61,20 +61,31 @@ class driver(inputDriver):
"Escape": "KEY_ESC", "Escape": "KEY_ESC",
"space": "KEY_SPACE", "space": "KEY_SPACE",
"minus": "KEY_MINUS", "minus": "KEY_MINUS",
"-": "KEY_MINUS",
"underscore": "KEY_MINUS", "underscore": "KEY_MINUS",
"_": "KEY_MINUS", "_": "KEY_MINUS",
"equal": "KEY_EQUAL", "equal": "KEY_EQUAL",
"=": "KEY_EQUAL",
"plus": "KEY_EQUAL", "plus": "KEY_EQUAL",
"+": "KEY_EQUAL", "+": "KEY_EQUAL",
"bracketleft": "KEY_LEFTBRACE", "bracketleft": "KEY_LEFTBRACE",
"[": "KEY_LEFTBRACE",
"bracketright": "KEY_RIGHTBRACE", "bracketright": "KEY_RIGHTBRACE",
"]": "KEY_RIGHTBRACE",
"backslash": "KEY_BACKSLASH", "backslash": "KEY_BACKSLASH",
"\\": "KEY_BACKSLASH",
"semicolon": "KEY_SEMICOLON", "semicolon": "KEY_SEMICOLON",
";": "KEY_SEMICOLON",
"apostrophe": "KEY_APOSTROPHE", "apostrophe": "KEY_APOSTROPHE",
"'": "KEY_APOSTROPHE",
"grave": "KEY_GRAVE", "grave": "KEY_GRAVE",
"`": "KEY_GRAVE",
"comma": "KEY_COMMA", "comma": "KEY_COMMA",
",": "KEY_COMMA",
"period": "KEY_DOT", "period": "KEY_DOT",
".": "KEY_DOT",
"slash": "KEY_SLASH", "slash": "KEY_SLASH",
"/": "KEY_SLASH",
"Shift_L": "KEY_LEFTSHIFT", "Shift_L": "KEY_LEFTSHIFT",
"Shift_R": "KEY_RIGHTSHIFT", "Shift_R": "KEY_RIGHTSHIFT",
"Control_L": "KEY_LEFTCTRL", "Control_L": "KEY_LEFTCTRL",
@@ -173,6 +184,7 @@ class driver(inputDriver):
self.command_key_active = False self.command_key_active = False
self.chord_modifiers = set() self.chord_modifiers = set()
self.chord_keys = set() self.chord_keys = set()
self.help_keyboard_grabbed = False
def initialize(self, environment): def initialize(self, environment):
self.env = environment self.env = environment
@@ -214,6 +226,7 @@ class driver(inputDriver):
self._initialized = True self._initialized = True
def shutdown(self): def shutdown(self):
self.set_help_capture(False)
self.ungrab_all_devices() self.ungrab_all_devices()
try: try:
if self.display: if self.display:
@@ -861,6 +874,54 @@ class driver(inputDriver):
pass pass
return True 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): def remove_all_devices(self):
self.ungrab_all_devices() self.ungrab_all_devices()
@@ -101,6 +101,98 @@ def test_bare_key_taps_advance_shortcut_repeat(monkeypatch):
assert manager.env["input"]["shortcut_repeat"] == 3 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 @pytest.mark.unit
def test_bare_key_taps_resolve_progressive_shortcuts(monkeypatch): def test_bare_key_taps_resolve_progressive_shortcuts(monkeypatch):
manager = create_input_manager() manager = create_input_manager()
+202
View File
@@ -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
)
@@ -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([2, word_keys])] == "REVIEW_CURR_WORD_SPELL"
assert bindings[str([3, word_keys])] == "REVIEW_CURR_WORD_PHONETIC" 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 = { removed_commands = {
"REVIEW_PREV_CHAR_PHONETIC", "REVIEW_PREV_CHAR_PHONETIC",
"REVIEW_NEXT_CHAR_PHONETIC", "REVIEW_NEXT_CHAR_PHONETIC",
+34
View File
@@ -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()
+75
View File
@@ -189,6 +189,29 @@ def test_x11_should_emit_unbound_mapped_keys_for_speech_interrupt():
assert x11.should_emit_key("KEY_ENTER") is True 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 @pytest.mark.unit
def test_x11_build_passive_grabs_for_fenrir_keys_and_shortcuts(): def test_x11_build_passive_grabs_for_fenrir_keys_and_shortcuts():
x11 = X11Driver() x11 = X11Driver()
@@ -372,6 +395,58 @@ def test_x11_write_event_buffer_does_not_replay_key_release():
assert x11.env["input"]["event_buffer"] == [] 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 @pytest.mark.unit
def test_x11_map_event_keeps_x_event_time_for_replay(): def test_x11_map_event_keeps_x_event_time_for_replay():
x11 = X11Driver() x11 = X11Driver()
+53 -5
View File
@@ -882,12 +882,24 @@ msgstr ""
msgid "speech enabled" msgid "speech enabled"
msgstr "" msgstr ""
#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:18 #: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:23
msgid "Exiting tutorial mode. To enter tutorial mode again press Fenrir+f1" msgid "enter or leave tutorial mode"
msgstr "" msgstr ""
#: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:22 #: ../src/fenrirscreenreader\commands\commands\toggle_tutorial_mode.py:28
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." 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 "" msgstr ""
#: ../src/fenrirscreenreader\commands\commands\toggle_vmenu_mode.py:18 #: ../src/fenrirscreenreader\commands\commands\toggle_vmenu_mode.py:18
@@ -906,6 +918,10 @@ msgstr ""
msgid "get current help message" msgid "get current help message"
msgstr "" msgstr ""
#: ../src/fenrirscreenreader\commands\help\next_help_section.py:21
msgid "show next help category"
msgstr ""
#: ../src/fenrirscreenreader\commands\help\next_help.py:17 #: ../src/fenrirscreenreader\commands\help\next_help.py:17
msgid "get next help message" msgid "get next help message"
msgstr "" msgstr ""
@@ -914,6 +930,10 @@ msgstr ""
msgid "get prev help message" msgid "get prev help message"
msgstr "" 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 #: ../src/fenrirscreenreader\commands\onCursorChange\65000-present_line_if_cursor_change_vertical.py:46
msgid "indented " msgid "indented "
@@ -1076,10 +1096,38 @@ msgstr ""
msgid "Quit Fenrir" msgid "Quit Fenrir"
msgstr "" 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" msgid "toggles the tutorial mode"
msgstr "" 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 #: ../src/fenrirscreenreader\core\outputManager.py:297
msgid "speech temporary disabled" msgid "speech temporary disabled"
msgstr "" msgstr ""