diff --git a/packages/capture/openadapt_capture/processing.py b/packages/capture/openadapt_capture/processing.py index 186857fd54..05544a9220 100644 --- a/packages/capture/openadapt_capture/processing.py +++ b/packages/capture/openadapt_capture/processing.py @@ -79,6 +79,23 @@ def _modifier_for(event: KeyDownEvent | KeyUpEvent) -> str | None: return _MODIFIER_ALIASES.get(_keyboard_key(event)) +def _typed_character(event: KeyDownEvent | KeyUpEvent) -> str | None: + """Return the character a key press types, if any. + + The macOS and X11 observers report the space bar as the named key + ``space`` with no character, so without this a space typed between + letters was dropped from the merged text and a lone space became an + empty KeyTypeEvent that downstream conversion could not map. + """ + + char = event.key_char or event.canonical_key_char + if char: + return char + if _keyboard_key(event) == "space": + return " " + return None + + def _first_structural_observation( events: list[ActionEvent], ) -> StructuralObservation | None: @@ -298,9 +315,7 @@ def flush_buffer() -> None: and len(triggers) == 1 and not ( modifiers == ["shift"] - and bool( - triggers[0].canonical_key_char or triggers[0].key_char - ) + and bool(_typed_character(triggers[0])) ) ) if is_single_shortcut: @@ -320,9 +335,9 @@ def flush_buffer() -> None: ) else: text = "".join( - event.key_char or event.canonical_key_char + char for event in down_events - if (event.key_char or event.canonical_key_char) + if (char := _typed_character(event)) is not None and _modifier_for(event) is None ) type_event = KeyTypeEvent( diff --git a/packages/capture/tests/test_processing.py b/packages/capture/tests/test_processing.py index e9906ee249..423bb7eac1 100644 --- a/packages/capture/tests/test_processing.py +++ b/packages/capture/tests/test_processing.py @@ -166,6 +166,65 @@ def test_shift_printable_character_remains_typed_text(self): assert isinstance(result[0], KeyTypeEvent) assert result[0].text == "A" + def test_named_space_key_is_typed_text(self): + # The macOS and X11 observers name the space bar without a character. + events = [ + KeyDownEvent(timestamp=1.0, key_name="space", canonical_key_name="space"), + KeyUpEvent(timestamp=1.1, key_name="space", canonical_key_name="space"), + ] + + result = merge_consecutive_keyboard_events(events) + + assert len(result) == 1 + assert isinstance(result[0], KeyTypeEvent) + assert result[0].text == " " + + def test_named_space_between_overlapping_letters_is_kept(self): + # Fast typing: "e" is still held when space goes down. + events = [ + KeyDownEvent(timestamp=1.0, key_char="e"), + KeyDownEvent(timestamp=1.1, key_name="space", canonical_key_name="space"), + KeyUpEvent(timestamp=1.2, key_char="e"), + KeyDownEvent(timestamp=1.3, key_char="n"), + KeyUpEvent(timestamp=1.4, key_name="space", canonical_key_name="space"), + KeyUpEvent(timestamp=1.5, key_char="n"), + ] + + result = merge_consecutive_keyboard_events(events) + + assert len(result) == 1 + assert isinstance(result[0], KeyTypeEvent) + assert result[0].text == "e n" + + def test_shift_space_is_typed_text(self): + events = [ + KeyDownEvent(timestamp=1.0, key_name="shift"), + KeyDownEvent(timestamp=1.1, key_name="space", canonical_key_name="space"), + KeyUpEvent(timestamp=1.2, key_name="space", canonical_key_name="space"), + KeyUpEvent(timestamp=1.3, key_name="shift"), + ] + + result = merge_consecutive_keyboard_events(events) + + assert len(result) == 1 + assert isinstance(result[0], KeyTypeEvent) + assert result[0].text == " " + + def test_control_space_stays_a_shortcut(self): + events = [ + KeyDownEvent(timestamp=1.0, key_name="control"), + KeyDownEvent(timestamp=1.1, key_name="space", canonical_key_name="space"), + KeyUpEvent(timestamp=1.2, key_name="space", canonical_key_name="space"), + KeyUpEvent(timestamp=1.3, key_name="control"), + ] + + result = merge_consecutive_keyboard_events(events) + + assert len(result) == 1 + assert isinstance(result[0], KeyShortcutEvent) + assert result[0].modifiers == ["ctrl"] + assert result[0].key == "space" + class TestMergeConsecutiveMouseMoveEvents: """Tests for merge_consecutive_mouse_move_events.""" diff --git a/packages/flow/openadapt_flow/adapters/capture.py b/packages/flow/openadapt_flow/adapters/capture.py index 55d3d964aa..839e6d6862 100644 --- a/packages/flow/openadapt_flow/adapters/capture.py +++ b/packages/flow/openadapt_flow/adapters/capture.py @@ -1030,6 +1030,14 @@ def _convert_key_type( f"flow equivalent (t={ts:.3f})" ) name = non_mods[0] + if name.lower() == "space" and not mods: + # The macOS and X11 observers report the space bar as the named key + # ``space`` with no text, so a recording made by an openadapt-capture + # that does not merge it into the typed text still types a space. + if not text_run["chars"]: + text_run["ts"] = ts + text_run["chars"].append(" ") + return mapped = _KEY_NAME_MAP.get(name.lower()) if mapped is None: raise ValueError(f"unmapped key {name!r} at t={ts:.3f}; extend _KEY_NAME_MAP") diff --git a/packages/flow/tests/test_capture_adapter.py b/packages/flow/tests/test_capture_adapter.py index e0bdd68f77..c2d16fd97c 100644 --- a/packages/flow/tests/test_capture_adapter.py +++ b/packages/flow/tests/test_capture_adapter.py @@ -662,6 +662,19 @@ def test_unknown_named_key_rejected(tmp_path: Path) -> None: convert_capture(capture_dir, tmp_path / "recording") +def test_named_space_key_joins_the_typed_text(tmp_path: Path) -> None: + # macOS and X11 capture report the space bar as the named key "space". + rows = _type_rows(T0 + 1.0, "synthetic") + rows += _named_key_rows(T0 + 1.2, "space") + rows += _type_rows(T0 + 1.3, "note") + capture_dir = make_capture(tmp_path, rows, screens=app_screens()[:1]) + recording_dir = tmp_path / "recording" + convert_capture(capture_dir, recording_dir) + events = events_of(recording_dir) + assert [e["kind"] for e in events] == ["type"] + assert events[0]["text"] == "synthetic note" + + def test_ambiguous_params_rejected(tmp_path: Path) -> None: capture_dir = make_capture(tmp_path, demo_rows()) with pytest.raises(ValueError, match="same value"):