diff --git a/config/keyboard/desktop.conf b/config/keyboard/desktop.conf index 121878df..7f850bf1 100644 --- a/config/keyboard/desktop.conf +++ b/config/keyboard/desktop.conf @@ -15,15 +15,12 @@ KEY_FENRIR,KEY_ALT,KEY_2=present_last_line KEY_KP5=review_curr_word KEY_KP4=review_prev_word KEY_KP6=review_next_word -KEY_FENRIR,KEY_SHIFT,KEY_KP5=review_curr_word_phonetic -KEY_FENRIR,KEY_SHIFT,KEY_KP4=review_prev_word_phonetic -KEY_FENRIR,KEY_SHIFT,KEY_KP6=review_next_word_phonetic +2,KEY_KP5=review_curr_word_spell +3,KEY_KP5=review_curr_word_phonetic KEY_KP2=review_curr_char KEY_KP1=review_prev_char KEY_KP3=review_next_char -KEY_FENRIR,KEY_SHIFT,KEY_KP2=review_curr_char_phonetic -KEY_FENRIR,KEY_SHIFT,KEY_KP1=review_prev_char_phonetic -KEY_FENRIR,KEY_SHIFT,KEY_KP3=review_next_char_phonetic +2,KEY_KP2=review_curr_char_phonetic KEY_FENRIR,KEY_CTRL,KEY_KP8=review_up KEY_FENRIR,KEY_CTRL,KEY_KP2=review_down KEY_FENRIR,KEY_KPDOT=exit_review diff --git a/config/keyboard/laptop.conf b/config/keyboard/laptop.conf index bffbdd3a..4bff806a 100644 --- a/config/keyboard/laptop.conf +++ b/config/keyboard/laptop.conf @@ -15,15 +15,12 @@ KEY_FENRIR,KEY_ALT,KEY_2=present_last_line KEY_FENRIR,KEY_K=review_curr_word KEY_FENRIR,KEY_J=review_prev_word KEY_FENRIR,KEY_L=review_next_word -KEY_FENRIR,KEY_ALT,KEY_K=review_curr_word_phonetic -KEY_FENRIR,KEY_ALT,KEY_J=review_prev_word_phonetic -KEY_FENRIR,KEY_ALT,KEY_L=review_next_word_phonetic +2,KEY_FENRIR,KEY_K=review_curr_word_spell +3,KEY_FENRIR,KEY_K=review_curr_word_phonetic KEY_FENRIR,KEY_COMMA=review_curr_char KEY_FENRIR,KEY_M=review_prev_char KEY_FENRIR,KEY_DOT=review_next_char -KEY_FENRIR,KEY_ALT,KEY_COMMA=curr_char_phonetic -KEY_FENRIR,KEY_ALT,KEY_M=prev_char_phonetic -KEY_FENRIR,KEY_ALT,KEY_DOT=next_char_phonetic +2,KEY_FENRIR,KEY_COMMA=review_curr_char_phonetic KEY_FENRIR,KEY_CTRL,KEY_I=review_up KEY_FENRIR,KEY_CTRL,KEY_COMMA=review_down KEY_FENRIR,KEY_SLASH=exit_review diff --git a/src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py b/src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py index 0dac32ef..7a4c80e0 100644 --- a/src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py +++ b/src/fenrirscreenreader/commands/commands/review_curr_word_phonetic.py @@ -38,7 +38,7 @@ class command: self.env["screen"]["new_content_text"], ) - if curr_word.isspace(): + if not curr_word or curr_word.isspace(): self.env["runtime"]["OutputManager"].present_text( _("blank"), interrupt=True, flush=False ) diff --git a/src/fenrirscreenreader/commands/commands/review_next_word_phonetic.py b/src/fenrirscreenreader/commands/commands/review_curr_word_spell.py similarity index 81% rename from src/fenrirscreenreader/commands/commands/review_next_word_phonetic.py rename to src/fenrirscreenreader/commands/commands/review_curr_word_spell.py index 45b7cb90..b64e6cb7 100644 --- a/src/fenrirscreenreader/commands/commands/review_next_word_phonetic.py +++ b/src/fenrirscreenreader/commands/commands/review_curr_word_spell.py @@ -20,7 +20,7 @@ class command: pass def get_description(self): - return _("Phonetically spells the next word and moves review to it") + return _("Spells the current word") def run(self): self.env["runtime"][ @@ -29,30 +29,28 @@ class command: ( self.env["screen"]["newCursorReview"]["x"], self.env["screen"]["newCursorReview"]["y"], - next_word, + curr_word, end_of_screen, line_break, - ) = word_utils.get_next_word( + ) = word_utils.get_current_word( self.env["screen"]["newCursorReview"]["x"], self.env["screen"]["newCursorReview"]["y"], self.env["screen"]["new_content_text"], ) - if next_word.isspace(): + if not curr_word or curr_word.isspace(): self.env["runtime"]["OutputManager"].present_text( _("blank"), interrupt=True, flush=False ) else: - first_sequence = True - for c in next_word: - curr_char = char_utils.get_phonetic(c) - self.env["runtime"]["OutputManager"].present_text( + for index, curr_char in enumerate(curr_word): + char_utils.present_char_for_review( + self.env, curr_char, - interrupt=first_sequence, + interrupt=index == 0, announce_capital=True, flush=False, ) - first_sequence = False if end_of_screen: if self.env["runtime"]["SettingsManager"].get_setting_as_bool( "review", "end_of_screen" diff --git a/src/fenrirscreenreader/commands/commands/review_next_char_phonetic.py b/src/fenrirscreenreader/commands/commands/review_next_char_phonetic.py deleted file mode 100644 index c3029864..00000000 --- a/src/fenrirscreenreader/commands/commands/review_next_char_phonetic.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "phonetically presents the next character and set review to it" - ) - - def run(self): - self.env["runtime"][ - "CursorManager" - ].enter_review_mode_curr_text_cursor() - - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - next_char, - end_of_screen, - line_break, - ) = char_utils.get_next_char( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - next_char = char_utils.get_phonetic(next_char) - self.env["runtime"]["OutputManager"].present_text( - next_char, interrupt=True, announce_capital=True, flush=False - ) - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("end of screen"), - interrupt=True, - sound_icon="EndOfScreen", - ) - if line_break: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "line_break" - ): - self.env["runtime"]["OutputManager"].present_text( - _("line break"), interrupt=False, sound_icon="EndOfLine" - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_prev_char_phonetic.py b/src/fenrirscreenreader/commands/commands/review_prev_char_phonetic.py deleted file mode 100644 index 805fca57..00000000 --- a/src/fenrirscreenreader/commands/commands/review_prev_char_phonetic.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "phonetically presents the previous character and set review to it" - ) - - def run(self): - self.env["runtime"][ - "CursorManager" - ].enter_review_mode_curr_text_cursor() - - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - prev_char, - end_of_screen, - line_break, - ) = char_utils.get_prev_char( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - prev_char = char_utils.get_phonetic(prev_char) - self.env["runtime"]["OutputManager"].present_text( - prev_char, interrupt=True, announce_capital=True, flush=False - ) - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("start of screen"), - interrupt=True, - sound_icon="StartOfScreen", - ) - if line_break: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "line_break" - ): - self.env["runtime"]["OutputManager"].present_text( - _("line break"), interrupt=False, sound_icon="EndOfLine" - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/review_prev_word_phonetic.py b/src/fenrirscreenreader/commands/commands/review_prev_word_phonetic.py deleted file mode 100644 index 0e9613f6..00000000 --- a/src/fenrirscreenreader/commands/commands/review_prev_word_phonetic.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils import char_utils -from fenrirscreenreader.utils import word_utils - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _( - "Phonetically spells the previous word and moves review to it" - ) - - def run(self): - self.env["runtime"][ - "CursorManager" - ].enter_review_mode_curr_text_cursor() - ( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - prev_word, - end_of_screen, - line_break, - ) = word_utils.get_prev_word( - self.env["screen"]["newCursorReview"]["x"], - self.env["screen"]["newCursorReview"]["y"], - self.env["screen"]["new_content_text"], - ) - - if prev_word.isspace(): - self.env["runtime"]["OutputManager"].present_text( - _("blank"), interrupt=True, flush=False - ) - else: - first_sequence = True - for c in prev_word: - curr_char = char_utils.get_phonetic(c) - self.env["runtime"]["OutputManager"].present_text( - curr_char, - interrupt=first_sequence, - announce_capital=True, - flush=False, - ) - first_sequence = False - if end_of_screen: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "end_of_screen" - ): - self.env["runtime"]["OutputManager"].present_text( - _("start of screen"), - interrupt=True, - sound_icon="StartOfScreen", - ) - if line_break: - if self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "review", "line_break" - ): - self.env["runtime"]["OutputManager"].present_text( - _("line break"), interrupt=False, sound_icon="EndOfLine" - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/core/fenrirManager.py b/src/fenrirscreenreader/core/fenrirManager.py index 60a4315a..ecc0c2a3 100644 --- a/src/fenrirscreenreader/core/fenrirManager.py +++ b/src/fenrirscreenreader/core/fenrirManager.py @@ -82,9 +82,6 @@ class FenrirManager: else: return - if self.environment["runtime"]["InputManager"].no_key_pressed(): - self.environment["runtime"]["InputManager"].clear_last_deep_input() - if self.environment["runtime"]["ScreenManager"].is_ignored_screen(): self.environment["runtime"]["InputManager"].write_event_buffer() else: diff --git a/src/fenrirscreenreader/core/inputManager.py b/src/fenrirscreenreader/core/inputManager.py index 1e78dbd6..8893659f 100644 --- a/src/fenrirscreenreader/core/inputManager.py +++ b/src/fenrirscreenreader/core/inputManager.py @@ -196,8 +196,6 @@ class InputManager: self.env["input"]["curr_input"] = sorted( self.env["input"]["curr_input"] ) - elif len(self.env["input"]["curr_input"]) == 0: - self.env["input"]["shortcut_repeat"] = 1 self.lastInputTime = time.time() elif event_data["event_state"] == 1: if not event_data["event_name"] in self.env["input"]["curr_input"]: @@ -206,19 +204,18 @@ class InputManager: self.env["input"]["curr_input"] = sorted( self.env["input"]["curr_input"] ) - if len(self.lastDeepestInput) < len( - self.env["input"]["curr_input"] - ): - self.set_last_deepest_input( - self.env["input"]["curr_input"].copy() - ) - elif self.lastDeepestInput == self.env["input"]["curr_input"]: + if self.lastDeepestInput == self.env["input"]["curr_input"]: if time.time() - self.lastInputTime <= self.env["runtime"][ "SettingsManager" ].get_setting_as_float("keyboard", "double_tap_timeout"): self.env["input"]["shortcut_repeat"] += 1 else: self.env["input"]["shortcut_repeat"] = 1 + else: + self.env["input"]["shortcut_repeat"] = 1 + self.set_last_deepest_input( + self.env["input"]["curr_input"].copy() + ) self.handle_led_states(event_data) self.lastInputTime = time.time() elif event_data["event_state"] == 2: diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 658a556b..8c276282 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -4,5 +4,5 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -version = "2026.08.12" +version = "2026.08.13" code_name = "testing" diff --git a/src/fenrirscreenreader/inputDriver/x11Driver.py b/src/fenrirscreenreader/inputDriver/x11Driver.py index c7b70d42..bb8742ec 100644 --- a/src/fenrirscreenreader/inputDriver/x11Driver.py +++ b/src/fenrirscreenreader/inputDriver/x11Driver.py @@ -172,6 +172,7 @@ class driver(inputDriver): self.modifier_interrupt_state = 0 self.command_key_active = False self.chord_modifiers = set() + self.chord_keys = set() def initialize(self, environment): self.env = environment @@ -306,6 +307,10 @@ class driver(inputDriver): def handle_x_event(self, event, event_queue): event_type = getattr(event, "type", None) + if event_type in [X.FocusIn, X.FocusOut] and getattr( + event, "mode", X.NotifyNormal + ) in [X.NotifyGrab, X.NotifyUngrab]: + return if event_type == X.FocusIn: self.active = True self.clear_event_buffer() @@ -329,6 +334,7 @@ class driver(inputDriver): if is_command_key and input_event["event_state"] == 1: self.start_command_chord(input_event, event_queue) elif self.command_key_active and not is_command_key: + self.track_chord_key(input_event) self.track_chord_modifier(input_event) self.write_debug( "x11Driver key event " @@ -361,6 +367,7 @@ class driver(inputDriver): def start_command_chord(self, input_event, event_queue): self.command_key_active = True + self.chord_keys.clear() raw_state = input_event.get("event_raw_state", 0) current_input = self.env["input"]["curr_input"] for key_name, modifier_mask in self.canonical_modifier_masks: @@ -368,7 +375,7 @@ class driver(inputDriver): continue self.chord_modifiers.add(key_name) if key_name not in current_input: - self.queue_synthetic_modifier( + self.queue_synthetic_key( key_name, 1, input_event, event_queue ) @@ -381,6 +388,15 @@ class driver(inputDriver): elif input_event["event_state"] == 0: self.chord_modifiers.discard(key_name) + def track_chord_key(self, input_event): + key_name = input_event["event_name"] + if key_name in self.modifier_key_names: + return + if input_event["event_state"] == 1: + self.chord_keys.add(key_name) + elif input_event["event_state"] == 0: + self.chord_keys.discard(key_name) + def canonical_modifier_name(self, key_name): modifier_mask = self.modifier_masks.get(key_name, 0) for canonical_name, canonical_mask in self.canonical_modifier_masks: @@ -389,12 +405,15 @@ class driver(inputDriver): return None def finish_command_chord(self, input_event, event_queue): + for key_name in sorted(self.chord_keys): + self.queue_synthetic_key(key_name, 0, input_event, event_queue) + self.chord_keys.clear() for key_name in sorted(self.chord_modifiers): - self.queue_synthetic_modifier(key_name, 0, input_event, event_queue) + self.queue_synthetic_key(key_name, 0, input_event, event_queue) self.chord_modifiers.clear() self.command_key_active = False - def queue_synthetic_modifier( + def queue_synthetic_key( self, key_name, event_state, source_event, event_queue ): synthetic_event = { @@ -781,6 +800,7 @@ class driver(inputDriver): def reset_input_state(self): self.command_key_active = False self.chord_modifiers.clear() + self.chord_keys.clear() try: self.env["runtime"]["InputManager"].reset_input_state() except Exception: diff --git a/tests/unit/test_fenrir_manager_shortcuts.py b/tests/unit/test_fenrir_manager_shortcuts.py index c037d975..e61a3c1b 100644 --- a/tests/unit/test_fenrir_manager_shortcuts.py +++ b/tests/unit/test_fenrir_manager_shortcuts.py @@ -7,6 +7,42 @@ from fenrirscreenreader.core.fenrirManager import FenrirManager from fenrirscreenreader.core.inputManager import InputManager +def create_input_manager(): + input_driver = Mock(get_led_state=Mock(return_value=False)) + settings_manager = Mock() + settings_manager.get_setting_as_float.return_value = 0.2 + manager = InputManager() + manager.env = { + "input": { + "curr_input": [], + "prev_input": [], + "event_buffer": [object()], + "shortcut_repeat": 1, + "old_num_lock": False, + "new_num_lock": False, + "old_caps_lock": False, + "new_caps_lock": False, + "old_scroll_lock": False, + "new_scroll_lock": False, + }, + "runtime": { + "DebugManager": Mock(), + "InputDriver": input_driver, + "SettingsManager": settings_manager, + }, + } + manager.lastDeepestInput = [] + manager.lastInputTime = 0 + manager.handle_led_states = Mock() + return manager + + +def send_input(manager, event_name, event_state): + manager.handle_input_event( + {"event_name": event_name, "event_state": event_state} + ) + + def create_handle_input_manager(no_key_pressed): manager = FenrirManager.__new__(FenrirManager) manager.modifierInput = False @@ -48,6 +84,100 @@ def test_input_manager_recognizes_conventional_modifier_prefix(): assert input_manager.curr_input_is_modifier_prefix() is False +@pytest.mark.unit +def test_bare_key_taps_advance_shortcut_repeat(monkeypatch): + manager = create_input_manager() + monkeypatch.setattr( + "fenrirscreenreader.core.inputManager.time.time", lambda: 1.0 + ) + + send_input(manager, "KEY_KP5", 1) + assert manager.env["input"]["shortcut_repeat"] == 1 + send_input(manager, "KEY_KP5", 0) + send_input(manager, "KEY_KP5", 1) + assert manager.env["input"]["shortcut_repeat"] == 2 + send_input(manager, "KEY_KP5", 0) + send_input(manager, "KEY_KP5", 1) + assert manager.env["input"]["shortcut_repeat"] == 3 + + +@pytest.mark.unit +def test_bare_key_taps_resolve_progressive_shortcuts(monkeypatch): + manager = create_input_manager() + monkeypatch.setattr( + "fenrirscreenreader.core.inputManager.time.time", lambda: 1.0 + ) + manager.env["runtime"]["CursorManager"] = Mock( + should_process_numpad_commands=Mock(return_value=True) + ) + manager.env["bindings"] = { + str([1, ["KEY_KP5"]]): "REVIEW_CURR_WORD", + str([2, ["KEY_KP5"]]): "REVIEW_CURR_WORD_SPELL", + str([3, ["KEY_KP5"]]): "REVIEW_CURR_WORD_PHONETIC", + } + + resolved_commands = [] + for _tap in range(3): + send_input(manager, "KEY_KP5", 1) + shortcut = manager.get_curr_shortcut() + resolved_commands.append(manager.get_command_for_shortcut(shortcut)) + send_input(manager, "KEY_KP5", 0) + + assert resolved_commands == [ + "REVIEW_CURR_WORD", + "REVIEW_CURR_WORD_SPELL", + "REVIEW_CURR_WORD_PHONETIC", + ] + + +@pytest.mark.unit +def test_different_bare_key_starts_new_repeat_sequence(monkeypatch): + manager = create_input_manager() + monkeypatch.setattr( + "fenrirscreenreader.core.inputManager.time.time", lambda: 1.0 + ) + + send_input(manager, "KEY_KP5", 1) + send_input(manager, "KEY_KP5", 0) + send_input(manager, "KEY_KP5", 1) + assert manager.env["input"]["shortcut_repeat"] == 2 + send_input(manager, "KEY_KP5", 0) + send_input(manager, "KEY_KP2", 1) + assert manager.env["input"]["shortcut_repeat"] == 1 + + +@pytest.mark.unit +def test_bare_key_tap_after_timeout_starts_new_sequence(monkeypatch): + manager = create_input_manager() + current_time = [1.0] + monkeypatch.setattr( + "fenrirscreenreader.core.inputManager.time.time", + lambda: current_time[0], + ) + + send_input(manager, "KEY_KP5", 1) + send_input(manager, "KEY_KP5", 0) + current_time[0] = 2.0 + send_input(manager, "KEY_KP5", 1) + + assert manager.env["input"]["shortcut_repeat"] == 1 + + +@pytest.mark.unit +def test_modifier_chord_taps_still_advance_shortcut_repeat(monkeypatch): + manager = create_input_manager() + monkeypatch.setattr( + "fenrirscreenreader.core.inputManager.time.time", lambda: 1.0 + ) + + send_input(manager, "KEY_FENRIR", 1) + send_input(manager, "KEY_T", 1) + assert manager.env["input"]["shortcut_repeat"] == 1 + send_input(manager, "KEY_T", 0) + send_input(manager, "KEY_T", 1) + assert manager.env["input"]["shortcut_repeat"] == 2 + + @pytest.mark.unit def test_first_conventional_modifier_starts_deferred_prefix(): manager = FenrirManager.__new__(FenrirManager) @@ -196,4 +326,5 @@ def test_modifier_prefix_is_forwarded_when_released_unused(): ) input_manager.write_event_buffer.assert_called_once_with() + input_manager.clear_last_deep_input.assert_not_called() assert manager.modifier_prefix_input is False diff --git a/tests/unit/test_review_spelling_commands.py b/tests/unit/test_review_spelling_commands.py new file mode 100644 index 00000000..3a18d233 --- /dev/null +++ b/tests/unit/test_review_spelling_commands.py @@ -0,0 +1,147 @@ +import importlib.util +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.core.inputManager import InputManager + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +COMMANDS_DIR = ( + PROJECT_ROOT / "src" / "fenrirscreenreader" / "commands" / "commands" +) +KEYBOARD_DIR = PROJECT_ROOT / "config" / "keyboard" + + +def load_command(name): + spec = importlib.util.spec_from_file_location( + f"fenrir_{name}", COMMANDS_DIR / f"{name}.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.command() + + +def run_command(name, content, cursor_x=0, cursor_y=0): + output_manager = Mock() + settings_manager = Mock() + settings_manager.get_setting_as_bool.return_value = True + env = { + "punctuation": {"PUNCTDICT": {" ": "space"}}, + "screen": { + "newCursorReview": {"x": cursor_x, "y": cursor_y}, + "new_content_text": content, + }, + "runtime": { + "CursorManager": Mock(), + "OutputManager": output_manager, + "SettingsManager": settings_manager, + }, + } + command = load_command(name) + command.initialize(env) + command.run() + return output_manager + + +def load_bindings(layout): + manager = InputManager() + manager.env = { + "bindings": {}, + "rawBindings": {}, + "runtime": {"DebugManager": Mock()}, + } + manager.load_shortcuts(KEYBOARD_DIR / f"{layout}.conf") + return manager.env["bindings"] + + +@pytest.mark.unit +def test_current_word_is_spelled_one_character_at_a_time(): + output_manager = run_command( + "review_curr_word_spell", "hello world", cursor_x=2 + ) + + calls = output_manager.present_text.call_args_list + assert [call.args[0] for call in calls] == list("hello") + assert calls[0].kwargs == { + "interrupt": True, + "ignore_punctuation": True, + "announce_capital": True, + "flush": False, + } + assert all(call.kwargs["interrupt"] is False for call in calls[1:]) + + +@pytest.mark.unit +def test_current_word_spelling_preserves_capitals_and_punctuation(): + output_manager = run_command("review_curr_word_spell", "Hi!", cursor_x=1) + + assert [ + call.args[0] for call in output_manager.present_text.call_args_list + ] == ["H", "i", "!"] + + +@pytest.mark.unit +@pytest.mark.parametrize( + "command_name", + ["review_curr_word_spell", "review_curr_word_phonetic"], +) +def test_current_word_spelling_announces_blank_when_no_word_exists( + command_name, +): + output_manager = run_command(command_name, " ") + + output_manager.present_text.assert_called_once_with( + "blank", interrupt=True, flush=False + ) + + +@pytest.mark.unit +def test_current_word_phonetic_spelling_uses_phonetic_names(): + output_manager = run_command( + "review_curr_word_phonetic", "Az!", cursor_x=1 + ) + + calls = output_manager.present_text.call_args_list + assert [call.args[0] for call in calls] == ["Alpha", "zulu", "!"] + assert calls[0].kwargs["interrupt"] is True + assert all(call.kwargs["interrupt"] is False for call in calls[1:]) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("layout", "character_keys", "word_keys"), + [ + ("desktop", ["KEY_KP2"], ["KEY_KP5"]), + ("laptop", ["KEY_COMMA", "KEY_FENRIR"], ["KEY_FENRIR", "KEY_K"]), + ], +) +def test_progressive_review_bindings(layout, character_keys, word_keys): + bindings = load_bindings(layout) + + assert bindings[str([1, character_keys])] == "REVIEW_CURR_CHAR" + assert bindings[str([2, character_keys])] == "REVIEW_CURR_CHAR_PHONETIC" + assert bindings[str([1, word_keys])] == "REVIEW_CURR_WORD" + assert bindings[str([2, word_keys])] == "REVIEW_CURR_WORD_SPELL" + assert bindings[str([3, word_keys])] == "REVIEW_CURR_WORD_PHONETIC" + + removed_commands = { + "REVIEW_PREV_CHAR_PHONETIC", + "REVIEW_NEXT_CHAR_PHONETIC", + "REVIEW_PREV_WORD_PHONETIC", + "REVIEW_NEXT_WORD_PHONETIC", + } + assert removed_commands.isdisjoint(bindings.values()) + + +@pytest.mark.unit +def test_obsolete_phonetic_navigation_commands_are_removed(): + removed_commands = [ + "review_prev_char_phonetic.py", + "review_next_char_phonetic.py", + "review_prev_word_phonetic.py", + "review_next_word_phonetic.py", + ] + + assert all(not (COMMANDS_DIR / name).exists() for name in removed_commands) diff --git a/tests/unit/test_x11_terminal_mode.py b/tests/unit/test_x11_terminal_mode.py index bf2f4a6b..08055a34 100644 --- a/tests/unit/test_x11_terminal_mode.py +++ b/tests/unit/test_x11_terminal_mode.py @@ -427,6 +427,43 @@ def test_x11_handle_key_event_keeps_event_buffer_for_input_manager(): event_queue.put.assert_called_once() +@pytest.mark.unit +@pytest.mark.parametrize( + ("event_type", "event_mode"), + [ + (X.FocusOut, X.NotifyGrab), + (X.FocusIn, X.NotifyUngrab), + ], +) +def test_x11_transient_grab_focus_events_preserve_input_state( + event_type, event_mode +): + x11 = X11Driver() + x11.active = True + x11.clear_event_buffer = Mock() + x11.reset_input_state = Mock() + event = Mock(type=event_type, mode=event_mode) + + x11.handle_x_event(event, Mock()) + + assert x11.active is True + x11.clear_event_buffer.assert_not_called() + x11.reset_input_state.assert_not_called() + + +@pytest.mark.unit +def test_x11_normal_focus_out_resets_input_state(): + x11 = X11Driver() + x11.active = True + x11.reset_input_state = Mock() + event = Mock(type=X.FocusOut, mode=X.NotifyNormal) + + x11.handle_x_event(event, Mock()) + + assert x11.active is False + x11.reset_input_state.assert_called_once_with() + + @pytest.mark.unit def test_x11_fenrir_press_emits_modifiers_from_raw_state_first(): x11 = X11Driver() @@ -485,3 +522,40 @@ def test_x11_fenrir_release_balances_synthesized_modifiers(): assert fenrir_event["data"]["event_name"] == "KEY_KP0" assert modifier_event["data"]["event_name"] == "KEY_CTRL" assert modifier_event["data"]["event_state"] == 0 + + +@pytest.mark.unit +def test_x11_fenrir_release_balances_held_chord_key(): + x11 = X11Driver() + x11.active = True + x11.fenrir_keys = {"KEY_KP0"} + x11.interesting_keys = {"KEY_KP0", "KEY_KP9"} + x11.env = { + "input": { + "curr_input": [], + "event_buffer": [], + }, + "runtime": { + "InputManager": Mock(convert_event_name=lambda key: key), + "DebugManager": Mock(), + }, + } + event_queue = Mock() + key_names = {90: "KEY_KP0", 81: "KEY_KP9"} + x11.keycode_to_key_name = Mock( + side_effect=lambda keycode, state=0: key_names[keycode] + ) + + x11.handle_x_event( + Mock(type=X.KeyPress, detail=90, state=0), event_queue + ) + x11.handle_x_event( + Mock(type=X.KeyPress, detail=81, state=0), event_queue + ) + x11.handle_x_event( + Mock(type=X.KeyRelease, detail=90, state=0), event_queue + ) + + synthetic_release = event_queue.put.call_args_list[-1].args[0] + assert synthetic_release["data"]["event_name"] == "KEY_KP9" + assert synthetic_release["data"]["event_state"] == 0 diff --git a/tools/fenrir.pot b/tools/fenrir.pot index e7b85c79..14ece9d0 100644 --- a/tools/fenrir.pot +++ b/tools/fenrir.pot @@ -108,13 +108,12 @@ msgstr "" #: ../src/fenrirscreenreader\commands\commands\review_curr_line.py:27 #: ../src/fenrirscreenreader\commands\commands\review_curr_word.py:27 #: ../src/fenrirscreenreader\commands\commands\review_curr_word_phonetic.py:27 +#: ../src/fenrirscreenreader\commands\commands\review_curr_word_spell.py:43 #: ../src/fenrirscreenreader\commands\commands\review_line_begin.py:27 #: ../src/fenrirscreenreader\commands\commands\review_next_line.py:29 #: ../src/fenrirscreenreader\commands\commands\review_next_word.py:29 -#: ../src/fenrirscreenreader\commands\commands\review_next_word_phonetic.py:27 #: ../src/fenrirscreenreader\commands\commands\review_prev_line.py:27 #: ../src/fenrirscreenreader\commands\commands\review_prev_word.py:27 -#: ../src/fenrirscreenreader\commands\commands\review_prev_word_phonetic.py:27 #: ../src/fenrirscreenreader\commands\onCursorChange\65000-present_line_if_cursor_change_vertical.py:37 #: ../src/fenrirscreenreader\commands\onScreenUpdate\60000-history.py:59 msgid "blank" @@ -513,31 +512,25 @@ msgstr "" #: ../src/fenrirscreenreader\commands\commands\review_curr_word.py:32 #: ../src/fenrirscreenreader\commands\commands\review_curr_word_phonetic.py:36 +#: ../src/fenrirscreenreader\commands\commands\review_curr_word_spell.py:59 #: ../src/fenrirscreenreader\commands\commands\review_down.py:27 #: ../src/fenrirscreenreader\commands\commands\review_next_char.py:28 -#: ../src/fenrirscreenreader\commands\commands\review_next_char_phonetic.py:30 #: ../src/fenrirscreenreader\commands\commands\review_next_line.py:34 #: ../src/fenrirscreenreader\commands\commands\review_next_word.py:34 -#: ../src/fenrirscreenreader\commands\commands\review_next_word_phonetic.py:36 #: ../src/fenrirscreenreader\commands\commands\review_prev_char.py:31 -#: ../src/fenrirscreenreader\commands\commands\review_prev_char_phonetic.py:30 #: ../src/fenrirscreenreader\commands\commands\review_prev_line.py:32 #: ../src/fenrirscreenreader\commands\commands\review_prev_word.py:32 -#: ../src/fenrirscreenreader\commands\commands\review_prev_word_phonetic.py:36 #: ../src/fenrirscreenreader\commands\commands\review_up.py:27 msgid "end of screen" msgstr "" #: ../src/fenrirscreenreader\commands\commands\review_curr_word.py:35 #: ../src/fenrirscreenreader\commands\commands\review_curr_word_phonetic.py:39 +#: ../src/fenrirscreenreader\commands\commands\review_curr_word_spell.py:68 #: ../src/fenrirscreenreader\commands\commands\review_next_char.py:31 -#: ../src/fenrirscreenreader\commands\commands\review_next_char_phonetic.py:33 #: ../src/fenrirscreenreader\commands\commands\review_next_word.py:37 -#: ../src/fenrirscreenreader\commands\commands\review_next_word_phonetic.py:39 #: ../src/fenrirscreenreader\commands\commands\review_prev_char.py:34 -#: ../src/fenrirscreenreader\commands\commands\review_prev_char_phonetic.py:33 #: ../src/fenrirscreenreader\commands\commands\review_prev_word.py:35 -#: ../src/fenrirscreenreader\commands\commands\review_prev_word_phonetic.py:39 #: ../src/fenrirscreenreader\commands\commands\review_up.py:30 msgid "line break" msgstr "" @@ -546,6 +539,10 @@ msgstr "" msgid "Phonetically spells the current word" msgstr "" +#: ../src/fenrirscreenreader\commands\commands\review_curr_word_spell.py:23 +msgid "Spells the current word" +msgstr "" + #: ../src/fenrirscreenreader\commands\commands\review_down.py:18 msgid "Move review to the character below the current position" msgstr "" @@ -590,10 +587,6 @@ msgstr "" msgid "Moves review to the next character " msgstr "" -#: ../src/fenrirscreenreader\commands\commands\review_next_char_phonetic.py:18 -msgid "phonetically presents the next character and set review to it" -msgstr "" - #: ../src/fenrirscreenreader\commands\commands\review_next_line.py:18 msgid "moves review to the next line " msgstr "" @@ -602,18 +595,10 @@ msgstr "" msgid "moves review to the next word " msgstr "" -#: ../src/fenrirscreenreader\commands\commands\review_next_word_phonetic.py:19 -msgid "Phonetically spells the next word and moves review to it" -msgstr "" - #: ../src/fenrirscreenreader\commands\commands\review_prev_char.py:18 msgid "moves review to the previous character " msgstr "" -#: ../src/fenrirscreenreader\commands\commands\review_prev_char_phonetic.py:18 -msgid "phonetically presents the previous character and set review to it" -msgstr "" - #: ../src/fenrirscreenreader\commands\commands\review_prev_line.py:18 msgid "moves review to the previous line " msgstr "" @@ -622,10 +607,6 @@ msgstr "" msgid "moves review focus to the previous word " msgstr "" -#: ../src/fenrirscreenreader\commands\commands\review_prev_word_phonetic.py:19 -msgid "Phonetically spells the previous word and moves review to it" -msgstr "" - #: ../src/fenrirscreenreader\commands\commands\review_top.py:18 msgid "move review to top of screen" msgstr ""