fix(inject): layout-aware clipboard paste for Neo and friends (#788)
* fix(inject): resolve paste chord for non-QWERTY layouts ydotool was sending physical KEY_V (47) for Ctrl+V. On German Neo that key types p, so paste opened Print instead. Prefer wtype keysyms when usable; otherwise resolve the evdev key for Latin v via xkbcommon, and read KDE kxkbrc so Plasma Wayland Neo users are covered. Fixes #787 * fix(inject): read Plasma active layout from real session sources kxkbrc has no CurrentLayout/LayoutIndex, so multi-layout Neo always fell back to us at index 0 and paste still hit KEY_V. Prefer D-Bus getLayout or layout_memory.xml for the active layout, keep kxkbrc for the list only, and prefer KDE over leftover GNOME on Plasma.
This commit is contained in:
@@ -68,6 +68,18 @@ def test_xkb_fr_a_is_key_q():
|
||||
assert char_map["q"] == ecodes.KEY_A
|
||||
|
||||
|
||||
def test_xkb_de_neo_v_is_key_w():
|
||||
"""German Neo v2: Latin v is physical KEY_W=17, KEY_V=47 types p (#787)."""
|
||||
char_map = build_char_to_evdev_map("de", "neo")
|
||||
if char_map is None:
|
||||
pytest.skip("libxkbcommon or XKB data not available")
|
||||
assert char_map["v"] == 17
|
||||
assert char_map["p"] == 47
|
||||
evdev = pytest.importorskip("evdev")
|
||||
assert char_map["v"] == evdev.ecodes.KEY_W
|
||||
assert char_map["p"] == evdev.ecodes.KEY_V
|
||||
|
||||
|
||||
# --- layout_key_map coverage: detection, cache, error paths ---
|
||||
|
||||
|
||||
@@ -170,18 +182,334 @@ def test_detect_gnome_layout_nonzero_exit(monkeypatch):
|
||||
assert lkm._detect_gnome_layout() == ("", "")
|
||||
|
||||
|
||||
def test_get_active_map_us_is_none(monkeypatch):
|
||||
_REALISTIC_KXKBRC = """[Layout]
|
||||
DisplayNames=,
|
||||
LayoutList=us,de
|
||||
VariantList=,neo
|
||||
Use=true
|
||||
"""
|
||||
|
||||
_NEO_LAYOUT_MEMORY = """<!DOCTYPE LayoutMap>
|
||||
<LayoutMap version="1.0" SwitchMode="Global">
|
||||
<item currentLayout="de(neo)"/>
|
||||
</LayoutMap>
|
||||
"""
|
||||
|
||||
|
||||
def _write_kxkbrc(tmp_path, content: str = _REALISTIC_KXKBRC):
|
||||
(tmp_path / "kxkbrc").write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def _write_layout_memory(tmp_path, xml: str, *, kded: str = "kded6"):
|
||||
session = tmp_path / kded / "keyboard" / "session"
|
||||
session.mkdir(parents=True, exist_ok=True)
|
||||
(session / "layout_memory.xml").write_text(xml, encoding="utf-8")
|
||||
|
||||
|
||||
def _isolate_kde_files(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(lkm, "_kde_dbus_layout_index", lambda: None)
|
||||
|
||||
|
||||
def _clear_plasma_env(monkeypatch) -> None:
|
||||
"""Force the GNOME-first detector order; ignore a KDE CI host's session vars."""
|
||||
for var in (
|
||||
"XDG_CURRENT_DESKTOP",
|
||||
"XDG_SESSION_DESKTOP",
|
||||
"DESKTOP_SESSION",
|
||||
"KDE_FULL_SESSION",
|
||||
"GDMSESSION",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
def test_match_token_empty_variant_uses_listed_variant():
|
||||
"""Bare ``de`` must pick the listed neo pair, not parse as de+empty."""
|
||||
pairs = [("us", ""), ("de", "neo")]
|
||||
assert lkm._match_token_to_listed_pairs("de", pairs) == ("de", "neo")
|
||||
assert lkm._match_token_to_listed_pairs("de(neo)", pairs) == ("de", "neo")
|
||||
assert lkm._match_token_to_listed_pairs("us", pairs) == ("us", "")
|
||||
|
||||
|
||||
def test_match_token_with_variant_prefers_exact_pair():
|
||||
pairs = [("de", "qwertz"), ("de", "neo")]
|
||||
assert lkm._match_token_to_listed_pairs("de(neo)", pairs) == ("de", "neo")
|
||||
assert lkm._match_token_to_listed_pairs("de(qwertz)", pairs) == ("de", "qwertz")
|
||||
assert lkm._match_token_to_listed_pairs("de", pairs) == ("de", "qwertz")
|
||||
|
||||
|
||||
def test_match_token_unique_layout_name_uses_listed_variant():
|
||||
pairs = [("fr", "oss"), ("de", "neo")]
|
||||
assert lkm._match_token_to_listed_pairs("de", pairs) == ("de", "neo")
|
||||
assert lkm._match_token_to_listed_pairs("fr", pairs) == ("fr", "oss")
|
||||
assert lkm._match_token_to_listed_pairs("it", pairs) == ("", "")
|
||||
|
||||
|
||||
def test_detect_kde_layout_memory_neo_not_index_zero(tmp_path, monkeypatch):
|
||||
"""Neo active in layout_memory, but us is LayoutList index 0 (issue #787)."""
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
_write_kxkbrc(tmp_path)
|
||||
_write_layout_memory(tmp_path, _NEO_LAYOUT_MEMORY)
|
||||
assert lkm._detect_kde_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_detect_kde_layout_memory_bare_de_keeps_neo_variant(tmp_path, monkeypatch):
|
||||
"""layout_memory currentLayout=de (no (neo)) still resolves VariantList neo."""
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
_write_kxkbrc(tmp_path)
|
||||
_write_layout_memory(
|
||||
tmp_path,
|
||||
'<LayoutMap version="1.0" SwitchMode="Global">' '<item currentLayout="de"/></LayoutMap>\n',
|
||||
)
|
||||
assert lkm._detect_kde_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_get_active_map_kde_neo_from_layout_memory(tmp_path, monkeypatch):
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
monkeypatch.setenv("XDG_CURRENT_DESKTOP", "KDE")
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("us", ""))
|
||||
_write_kxkbrc(tmp_path)
|
||||
_write_layout_memory(tmp_path, _NEO_LAYOUT_MEMORY)
|
||||
char_map = lkm.get_active_char_to_evdev_map()
|
||||
if char_map is None:
|
||||
pytest.skip("libxkbcommon or XKB data not available")
|
||||
assert char_map["v"] == 17
|
||||
assert char_map["p"] == 47
|
||||
|
||||
|
||||
def test_detect_kde_layout_missing_files(tmp_path, monkeypatch):
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
assert lkm._detect_kde_layout() == ("", "")
|
||||
|
||||
|
||||
def test_detect_kde_layout_quoted_token(tmp_path, monkeypatch):
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
_write_kxkbrc(
|
||||
tmp_path,
|
||||
'[Layout]\nLayoutList[$i]="us,de"\nVariantList[$i]=",neo"\nUse=true\n',
|
||||
)
|
||||
_write_layout_memory(
|
||||
tmp_path,
|
||||
'<LayoutMap version="1.0" SwitchMode="Global">'
|
||||
"<item currentLayout=\"'de(neo)'\"/></LayoutMap>\n",
|
||||
)
|
||||
assert lkm._detect_kde_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_detect_kde_layout_memory_without_kxkbrc(tmp_path, monkeypatch):
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
_write_layout_memory(tmp_path, _NEO_LAYOUT_MEMORY)
|
||||
assert lkm._detect_kde_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_detect_kde_layout_ignores_invented_kxkbrc_keys(tmp_path, monkeypatch):
|
||||
"""CurrentLayout/LayoutIndex are not in keyboardsettings.kcfg; do not use them."""
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
_write_kxkbrc(
|
||||
tmp_path,
|
||||
"[Layout]\nLayoutList=us,de\nVariantList=,neo\n"
|
||||
"CurrentLayout=de(neo)\nLayoutIndex=1\nUse=true\n",
|
||||
)
|
||||
assert lkm._detect_kde_layout() == ("", "")
|
||||
|
||||
|
||||
def test_detect_kde_layout_single_list_entry(tmp_path, monkeypatch):
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
_write_kxkbrc(tmp_path, "[Layout]\nLayoutList=de\nVariantList=neo\nUse=true\n")
|
||||
assert lkm._detect_kde_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_detect_kde_layout_kded5_fallback(tmp_path, monkeypatch):
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
_write_kxkbrc(tmp_path)
|
||||
_write_layout_memory(tmp_path, _NEO_LAYOUT_MEMORY, kded="kded5")
|
||||
assert lkm._detect_kde_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_detect_kde_layout_prefers_kded6_over_kded5(tmp_path, monkeypatch):
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
_write_kxkbrc(tmp_path)
|
||||
_write_layout_memory(
|
||||
tmp_path,
|
||||
'<LayoutMap SwitchMode="Global"><item currentLayout="de(neo)"/></LayoutMap>\n',
|
||||
kded="kded6",
|
||||
)
|
||||
_write_layout_memory(
|
||||
tmp_path,
|
||||
'<LayoutMap SwitchMode="Global"><item currentLayout="us"/></LayoutMap>\n',
|
||||
kded="kded5",
|
||||
)
|
||||
assert lkm._detect_kde_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_detect_kde_layout_malformed_memory_falls_through(tmp_path, monkeypatch):
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
_write_kxkbrc(tmp_path)
|
||||
_write_layout_memory(tmp_path, "<not-xml", kded="kded6")
|
||||
_write_layout_memory(tmp_path, _NEO_LAYOUT_MEMORY, kded="kded5")
|
||||
assert lkm._detect_kde_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_detect_kde_layout_multi_item_matches_list(tmp_path, monkeypatch):
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
_write_kxkbrc(tmp_path)
|
||||
_write_layout_memory(
|
||||
tmp_path,
|
||||
"""<LayoutMap version="1.0" SwitchMode="WinClass">
|
||||
<item currentLayout="fr" ownerKey="unused"/>
|
||||
<item currentLayout="de(neo)" ownerKey="konsole"/>
|
||||
</LayoutMap>
|
||||
""",
|
||||
)
|
||||
assert lkm._detect_kde_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_detect_kde_layout_multi_item_bare_de_keeps_neo(tmp_path, monkeypatch):
|
||||
_isolate_kde_files(tmp_path, monkeypatch)
|
||||
_write_kxkbrc(tmp_path)
|
||||
_write_layout_memory(
|
||||
tmp_path,
|
||||
"""<LayoutMap version="1.0" SwitchMode="WinClass">
|
||||
<item currentLayout="fr" ownerKey="unused"/>
|
||||
<item currentLayout="de" ownerKey="konsole"/>
|
||||
</LayoutMap>
|
||||
""",
|
||||
)
|
||||
assert lkm._detect_kde_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_kde_dbus_layout_index_parses_uint32(monkeypatch):
|
||||
def fake_run(cmd, **kwargs):
|
||||
assert cmd[0] == "gdbus"
|
||||
assert "--session" in cmd
|
||||
assert "org.kde.keyboard" in cmd
|
||||
assert "/Layouts" in cmd
|
||||
assert "org.kde.KeyboardLayouts.getLayout" in cmd
|
||||
assert kwargs.get("timeout") == 2
|
||||
assert kwargs.get("env") is not None
|
||||
return types.SimpleNamespace(returncode=0, stdout="(uint32 1,)\n")
|
||||
|
||||
monkeypatch.setattr(lkm.subprocess, "run", fake_run)
|
||||
assert lkm._kde_dbus_layout_index() == 1
|
||||
|
||||
|
||||
def test_kde_dbus_layout_index_parses_bare_int(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
lkm.subprocess,
|
||||
"run",
|
||||
lambda *_a, **_k: types.SimpleNamespace(returncode=0, stdout="(1,)\n"),
|
||||
)
|
||||
assert lkm._kde_dbus_layout_index() == 1
|
||||
|
||||
|
||||
def test_kde_dbus_layout_index_missing_gdbus(monkeypatch):
|
||||
def boom(*_a, **_k):
|
||||
raise FileNotFoundError("gdbus")
|
||||
|
||||
monkeypatch.setattr(lkm.subprocess, "run", boom)
|
||||
assert lkm._kde_dbus_layout_index() is None
|
||||
|
||||
|
||||
def test_kde_dbus_layout_index_nonzero_exit(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
lkm.subprocess,
|
||||
"run",
|
||||
lambda *_a, **_k: types.SimpleNamespace(returncode=1, stdout=""),
|
||||
)
|
||||
assert lkm._kde_dbus_layout_index() is None
|
||||
|
||||
|
||||
def test_detect_kde_prefers_dbus_index_over_memory(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(lkm, "_kde_dbus_layout_index", lambda: 1)
|
||||
_write_kxkbrc(tmp_path)
|
||||
_write_layout_memory(
|
||||
tmp_path,
|
||||
'<LayoutMap SwitchMode="Global"><item currentLayout="us"/></LayoutMap>\n',
|
||||
)
|
||||
assert lkm._detect_kde_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_detect_kde_dbus_out_of_range_falls_through_to_memory(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(lkm, "_kde_dbus_layout_index", lambda: 9)
|
||||
_write_kxkbrc(tmp_path)
|
||||
_write_layout_memory(tmp_path, _NEO_LAYOUT_MEMORY)
|
||||
assert lkm._detect_kde_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_is_plasma_session_reads_desktop_vars(monkeypatch):
|
||||
_clear_plasma_env(monkeypatch)
|
||||
assert not lkm._is_plasma_session()
|
||||
monkeypatch.setenv("XDG_SESSION_DESKTOP", "plasmawayland")
|
||||
assert lkm._is_plasma_session()
|
||||
_clear_plasma_env(monkeypatch)
|
||||
monkeypatch.setenv("KDE_FULL_SESSION", "true")
|
||||
assert lkm._is_plasma_session()
|
||||
|
||||
|
||||
def test_detect_active_layout_prefers_gnome_outside_plasma(monkeypatch):
|
||||
_clear_plasma_env(monkeypatch)
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("fr", "oss"))
|
||||
monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("de", "neo"))
|
||||
assert lkm._detect_active_layout() == ("fr", "oss")
|
||||
|
||||
|
||||
def test_detect_active_layout_plasma_prefers_kde_over_gnome(monkeypatch):
|
||||
monkeypatch.setenv("XDG_CURRENT_DESKTOP", "KDE")
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("us", ""))
|
||||
monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("de", "neo"))
|
||||
assert lkm._detect_active_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_detect_active_layout_falls_back_to_kde(monkeypatch):
|
||||
_clear_plasma_env(monkeypatch)
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("", ""))
|
||||
monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("de", "neo"))
|
||||
assert lkm._detect_active_layout() == ("de", "neo")
|
||||
|
||||
|
||||
def test_detect_active_layout_plasma_falls_back_to_gnome(monkeypatch):
|
||||
monkeypatch.setenv("XDG_CURRENT_DESKTOP", "plasma")
|
||||
monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("", ""))
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("fr", "oss"))
|
||||
assert lkm._detect_active_layout() == ("fr", "oss")
|
||||
|
||||
|
||||
def test_get_active_map_us_is_none(monkeypatch):
|
||||
"""GNOME us → None only outside Plasma (KDE neo must not win this path)."""
|
||||
_clear_plasma_env(monkeypatch)
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("us", ""))
|
||||
monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("de", "neo"))
|
||||
assert lkm.get_active_char_to_evdev_map() is None
|
||||
|
||||
|
||||
def test_get_active_map_plasma_leftover_gnome_us_loses_to_kde_neo(monkeypatch):
|
||||
"""Leftover GNOME us must not hide Plasma neo (issue #787)."""
|
||||
monkeypatch.setenv("XDG_CURRENT_DESKTOP", "KDE")
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("us", ""))
|
||||
monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("de", "neo"))
|
||||
char_map = lkm.get_active_char_to_evdev_map()
|
||||
if char_map is None:
|
||||
pytest.skip("libxkbcommon or XKB data not available")
|
||||
assert char_map["v"] == 17
|
||||
assert char_map["p"] == 47
|
||||
|
||||
|
||||
def test_get_active_map_empty_layout(monkeypatch):
|
||||
_clear_plasma_env(monkeypatch)
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("", ""))
|
||||
monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("", ""))
|
||||
assert lkm.get_active_char_to_evdev_map() is None
|
||||
|
||||
|
||||
def test_get_active_map_loads_fr(monkeypatch):
|
||||
_clear_plasma_env(monkeypatch)
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("fr", ""))
|
||||
monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("", ""))
|
||||
char_map = lkm.get_active_char_to_evdev_map()
|
||||
if char_map is None:
|
||||
pytest.skip("libxkbcommon or XKB data not available")
|
||||
@@ -191,11 +519,24 @@ def test_get_active_map_loads_fr(monkeypatch):
|
||||
|
||||
|
||||
def test_get_active_map_when_build_fails(monkeypatch):
|
||||
_clear_plasma_env(monkeypatch)
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("fr", ""))
|
||||
monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("", ""))
|
||||
monkeypatch.setattr(lkm, "build_char_to_evdev_map", lambda *_a, **_k: None)
|
||||
assert lkm.get_active_char_to_evdev_map() is None
|
||||
|
||||
|
||||
def test_get_active_map_uses_kde_when_gnome_empty(monkeypatch):
|
||||
_clear_plasma_env(monkeypatch)
|
||||
monkeypatch.setattr(lkm, "_detect_gnome_layout", lambda: ("", ""))
|
||||
monkeypatch.setattr(lkm, "_detect_kde_layout", lambda: ("de", "neo"))
|
||||
char_map = lkm.get_active_char_to_evdev_map()
|
||||
if char_map is None:
|
||||
pytest.skip("libxkbcommon or XKB data not available")
|
||||
assert char_map["v"] == 17
|
||||
assert char_map["p"] == 47
|
||||
|
||||
|
||||
def test_build_skips_keys_with_no_levels(monkeypatch):
|
||||
"""Keys with zero levels are skipped (line covered via stub lib)."""
|
||||
|
||||
|
||||
@@ -108,12 +108,17 @@ class TestShouldUseTerminalPaste(unittest.TestCase):
|
||||
self.assertEqual(obj._paste_shortcut_preference(), "ctrl+shift+v")
|
||||
|
||||
|
||||
_NEO_CHAR_MAP = {"v": 17, "p": 47} # de(neo): Latin v is KEY_W, KEY_V types p
|
||||
_LAYOUT_MAP = "vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map"
|
||||
|
||||
|
||||
class TestYdotoolTerminalPasteCommand(unittest.TestCase):
|
||||
"""Terminal paste must use Ctrl+Shift+V in both ydotool dialects."""
|
||||
|
||||
@patch(_LAYOUT_MAP, return_value=None)
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_legacy_named_sequence(self, mock_which, mock_run):
|
||||
def test_legacy_named_sequence(self, mock_which, mock_run, _mock_map):
|
||||
mock_which.return_value = "/usr/bin/ydotool"
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
@@ -128,8 +133,9 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(injector._ydotool_ctrl_v_command(), ["ydotool", "key", "ctrl+v"])
|
||||
|
||||
@patch(_LAYOUT_MAP, return_value=None)
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_flatpak_uses_shift_keycodes(self, mock_which):
|
||||
def test_flatpak_uses_shift_keycodes(self, mock_which, _mock_map):
|
||||
mock_which.return_value = "/app/bin/ydotool"
|
||||
injector = _make_injector()
|
||||
with patch.dict("os.environ", {"FLATPAK_ID": "com.vocalinux.Vocalinux"}):
|
||||
@@ -142,6 +148,100 @@ class TestYdotoolTerminalPasteCommand(unittest.TestCase):
|
||||
["ydotool", "key", "29:1", "47:1", "47:0", "29:0"],
|
||||
)
|
||||
|
||||
@patch(_LAYOUT_MAP, return_value=_NEO_CHAR_MAP)
|
||||
def test_ydotool_v1_uses_layout_keycode_for_v(self, _mock_map):
|
||||
"""de(neo) paste must hit KEY_W=17, not QWERTY KEY_V=47 (issue #787)."""
|
||||
injector = _make_injector()
|
||||
injector._ydotool_legacy_named_keys = False
|
||||
self.assertEqual(
|
||||
injector._ydotool_ctrl_v_command(),
|
||||
["ydotool", "key", "29:1", "17:1", "17:0", "29:0"],
|
||||
)
|
||||
self.assertEqual(
|
||||
injector._ydotool_ctrl_v_command(terminal=True),
|
||||
["ydotool", "key", "29:1", "42:1", "17:1", "17:0", "42:0", "29:0"],
|
||||
)
|
||||
for cmd in (
|
||||
injector._ydotool_ctrl_v_command(),
|
||||
injector._ydotool_ctrl_v_command(terminal=True),
|
||||
):
|
||||
self.assertNotIn("47:1", cmd)
|
||||
self.assertNotIn("47:0", cmd)
|
||||
|
||||
@patch(_LAYOUT_MAP, return_value=None)
|
||||
def test_ydotool_v1_us_map_uses_key_v(self, _mock_map):
|
||||
injector = _make_injector()
|
||||
injector._ydotool_legacy_named_keys = False
|
||||
self.assertEqual(
|
||||
injector._ydotool_ctrl_v_command(),
|
||||
["ydotool", "key", "29:1", "47:1", "47:0", "29:0"],
|
||||
)
|
||||
|
||||
@patch(_LAYOUT_MAP, return_value={})
|
||||
def test_ydotool_v1_empty_map_uses_key_v(self, _mock_map):
|
||||
injector = _make_injector()
|
||||
injector._ydotool_legacy_named_keys = False
|
||||
self.assertEqual(
|
||||
injector._ydotool_ctrl_v_command(),
|
||||
["ydotool", "key", "29:1", "47:1", "47:0", "29:0"],
|
||||
)
|
||||
|
||||
@patch(_LAYOUT_MAP, return_value=_NEO_CHAR_MAP)
|
||||
def test_ydotool_legacy_uses_linux_name_for_layout_v(self, _mock_map):
|
||||
"""0.1.x named ctrl+v is physical KEY_V; Neo must emit ctrl+w."""
|
||||
injector = _make_injector()
|
||||
injector._ydotool_legacy_named_keys = True
|
||||
self.assertEqual(injector._ydotool_ctrl_v_command(), ["ydotool", "key", "ctrl+w"])
|
||||
self.assertEqual(
|
||||
injector._ydotool_ctrl_v_command(terminal=True),
|
||||
["ydotool", "key", "ctrl+shift+w"],
|
||||
)
|
||||
|
||||
def test_prefers_wtype_keysyms_for_paste_chord(self):
|
||||
"""Paste may use wtype keysyms even when typing stays on ydotool."""
|
||||
injector = _make_injector()
|
||||
injector.wayland_tool = "ydotool"
|
||||
with patch.object(injector, "_wtype_usable_for_paste", return_value=True):
|
||||
cmd = injector._clipboard_paste_command()
|
||||
terminal = injector._clipboard_paste_command(terminal=True)
|
||||
self.assertEqual(cmd, ["wtype", "-M", "ctrl", "v"])
|
||||
self.assertEqual(terminal, ["wtype", "-M", "ctrl", "-M", "shift", "v"])
|
||||
self.assertIn("v", cmd)
|
||||
self.assertNotIn("47", cmd)
|
||||
self.assertNotIn("47:1", cmd)
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
def test_clipboard_paste_uses_wtype_argv_when_chosen(self, mock_run, mock_which):
|
||||
mock_which.side_effect = lambda cmd: cmd in ("wl-copy", "wtype", "ydotool")
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
injector = _make_injector()
|
||||
injector.wayland_tool = "ydotool"
|
||||
with patch.object(injector, "_wtype_usable_for_paste", return_value=True):
|
||||
with patch.object(injector, "_copy_to_clipboard", return_value=True):
|
||||
with patch.object(injector, "_read_clipboard", return_value="old"):
|
||||
with patch.object(injector, "_should_use_terminal_paste", return_value=False):
|
||||
self.assertTrue(injector._inject_via_clipboard_paste("hello"))
|
||||
paste_cmds = [c.args[0] for c in mock_run.call_args_list if c.args]
|
||||
self.assertTrue(
|
||||
any(c == ["wtype", "-M", "ctrl", "v"] for c in paste_cmds),
|
||||
paste_cmds,
|
||||
)
|
||||
self.assertFalse(any("47:1" in c for c in paste_cmds))
|
||||
|
||||
def test_wtype_paste_skipped_on_kde_when_typing_backend_is_ydotool(self):
|
||||
injector = _make_injector()
|
||||
injector.wayland_tool = "ydotool"
|
||||
with patch(
|
||||
"vocalinux.text_injection.text_injector._is_kde_plasma_session",
|
||||
return_value=True,
|
||||
):
|
||||
with patch(
|
||||
"vocalinux.text_injection.text_injector.shutil.which",
|
||||
side_effect=lambda cmd: "/usr/bin/wtype" if cmd == "wtype" else None,
|
||||
):
|
||||
self.assertFalse(injector._wtype_usable_for_paste())
|
||||
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
def test_clipboard_paste_sends_terminal_chord(self, mock_run, mock_which):
|
||||
|
||||
@@ -615,8 +615,8 @@ class TestTextInjector(unittest.TestCase):
|
||||
|
||||
`ydotool type` emits positional evdev keycodes that get re-interpreted
|
||||
through the active keyboard layout (assumed US QWERTY), so typing ASCII
|
||||
is scrambled on non-US layouts (e.g. AZERTY). Clipboard paste (Ctrl+V)
|
||||
is layout-independent, so it is used unconditionally for ydotool.
|
||||
is scrambled on non-US layouts (e.g. AZERTY). Clipboard paste is used
|
||||
unconditionally for ydotool so the payload is not typed key-by-key.
|
||||
"""
|
||||
mock_which.side_effect = lambda x: x in ("ydotool", "wl-copy")
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
@@ -755,9 +755,15 @@ class TestTextInjector(unittest.TestCase):
|
||||
"wtype must NOT use clipboard-paste workaround",
|
||||
)
|
||||
|
||||
@patch(
|
||||
"vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map",
|
||||
return_value=None,
|
||||
)
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_ydotool_ctrl_v_command_legacy_uses_named_sequence(self, mock_which, mock_run):
|
||||
def test_ydotool_ctrl_v_command_legacy_uses_named_sequence(
|
||||
self, mock_which, mock_run, _mock_map
|
||||
):
|
||||
"""Distro ydotool 0.1.x expects ctrl+v, not keycode:value."""
|
||||
mock_which.return_value = "/usr/bin/ydotool"
|
||||
mock_run.return_value = MagicMock(
|
||||
@@ -776,8 +782,12 @@ class TestTextInjector(unittest.TestCase):
|
||||
self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"])
|
||||
self.assertEqual(injector._ydotool_ctrl_v_command(), ["ydotool", "key", "ctrl+v"])
|
||||
|
||||
@patch(
|
||||
"vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map",
|
||||
return_value=None,
|
||||
)
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_ydotool_ctrl_v_command_flatpak_uses_keycodes(self, mock_which):
|
||||
def test_ydotool_ctrl_v_command_flatpak_uses_keycodes(self, mock_which, _mock_map):
|
||||
"""Flatpak pins ydotool 1.0.4; always use keycode:value form."""
|
||||
mock_which.return_value = "/app/bin/ydotool"
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
@@ -785,9 +795,13 @@ class TestTextInjector(unittest.TestCase):
|
||||
cmd = injector._ydotool_ctrl_v_command()
|
||||
self.assertEqual(cmd, ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
|
||||
|
||||
@patch(
|
||||
"vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map",
|
||||
return_value=None,
|
||||
)
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_ydotool_ctrl_v_command_v1_help_uses_keycodes(self, mock_which, mock_run):
|
||||
def test_ydotool_ctrl_v_command_v1_help_uses_keycodes(self, mock_which, mock_run, _mock_map):
|
||||
"""Host ydotool 1.x (no plus-sequence help) uses press/release keycodes."""
|
||||
mock_which.return_value = "/usr/local/bin/ydotool"
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="Usage: key N:1 N:0 ...", stderr="")
|
||||
@@ -796,9 +810,15 @@ class TestTextInjector(unittest.TestCase):
|
||||
cmd = injector._ydotool_ctrl_v_command()
|
||||
self.assertEqual(cmd, ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
|
||||
|
||||
@patch(
|
||||
"vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map",
|
||||
return_value=None,
|
||||
)
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_ydotool_ctrl_v_command_unknown_help_defaults_legacy(self, mock_which, mock_run):
|
||||
def test_ydotool_ctrl_v_command_unknown_help_defaults_legacy(
|
||||
self, mock_which, mock_run, _mock_map
|
||||
):
|
||||
"""Unrecognized help text prefers named ctrl+v (safe on 0.1.x)."""
|
||||
mock_which.return_value = "/usr/bin/ydotool"
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="mystery help", stderr="")
|
||||
@@ -807,9 +827,15 @@ class TestTextInjector(unittest.TestCase):
|
||||
cmd = injector._ydotool_ctrl_v_command()
|
||||
self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"])
|
||||
|
||||
@patch(
|
||||
"vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map",
|
||||
return_value=None,
|
||||
)
|
||||
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_ydotool_ctrl_v_command_probe_error_defaults_legacy(self, mock_which, mock_run):
|
||||
def test_ydotool_ctrl_v_command_probe_error_defaults_legacy(
|
||||
self, mock_which, mock_run, _mock_map
|
||||
):
|
||||
"""If key --help fails, default to legacy named sequence."""
|
||||
mock_which.return_value = "/usr/bin/ydotool"
|
||||
mock_run.side_effect = OSError("no ydotool")
|
||||
@@ -818,8 +844,12 @@ class TestTextInjector(unittest.TestCase):
|
||||
cmd = injector._ydotool_ctrl_v_command()
|
||||
self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"])
|
||||
|
||||
@patch(
|
||||
"vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map",
|
||||
return_value=None,
|
||||
)
|
||||
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
||||
def test_ydotool_ctrl_v_command_app_prefix_uses_keycodes(self, mock_which):
|
||||
def test_ydotool_ctrl_v_command_app_prefix_uses_keycodes(self, mock_which, _mock_map):
|
||||
"""/app/bin/ydotool (Flatpak path) always uses 1.x keycodes without FLATPAK_ID."""
|
||||
mock_which.return_value = "/app/bin/ydotool"
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
|
||||
Reference in New Issue
Block a user