Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions packages/capture/openadapt_capture/processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down
59 changes: 59 additions & 0 deletions packages/capture/tests/test_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
8 changes: 8 additions & 0 deletions packages/flow/openadapt_flow/adapters/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
13 changes: 13 additions & 0 deletions packages/flow/tests/test_capture_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
Loading