more tests, disabling keys

xkb
sezanzeb 4 years ago committed by sezanzeb
parent d9ee9fb978
commit 8454707748

@ -816,32 +816,6 @@
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkSeparator">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="keycode">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="tooltip-text" translatable="yes">This is the keycode you just pressed, and that you see in the mappings to the right.</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="width-chars">4</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">4</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>

@ -177,7 +177,7 @@ class GlobalConfig(ConfigBase):
def __init__(self):
self.path = os.path.join(CONFIG_PATH, 'config.json')
# migrate from < 0.4.1, add the .json ending
# migrate from < 0.4.0, add the .json ending
deprecated_path = os.path.join(CONFIG_PATH, 'config')
if os.path.exists(deprecated_path) and not os.path.exists(self.path):
logger.info('Moving "%s" to "%s"', deprecated_path, self.path)

@ -38,6 +38,7 @@ from keymapper.dev.keycode_mapper import handle_keycode, \
from keymapper.dev.ev_abs_mapper import ev_abs_mapper, JOYSTICK
from keymapper.dev.macros import parse, is_this_a_macro
from keymapper.state import system_mapping
from keymapper.mapping import DISABLE_CODE
DEV_NAME = 'key-mapper'
@ -261,6 +262,9 @@ class KeycodeInjector:
# Furthermore, support all injected keycodes
for code in self._key_to_code.values():
if code == DISABLE_CODE:
continue
if code not in capabilities[EV_KEY]:
capabilities[EV_KEY].append(code)

@ -27,8 +27,9 @@ import asyncio
from evdev.ecodes import EV_KEY, EV_ABS
from keymapper.logger import logger
from keymapper.logger import logger, is_debug
from keymapper.util import sign
from keymapper.mapping import DISABLE_CODE
from keymapper.dev.ev_abs_mapper import JOYSTICK
@ -118,6 +119,25 @@ def subsets(combination):
))
def log(key, msg, *args):
"""Function that logs nicely formatted spams."""
if not is_debug():
return
msg = msg % args
str_key = str(key)
str_key = str_key.replace(',)', ')')
spacing = ' ' + '-' * max(0, 30 - len(str_key))
if len(spacing) == 1:
spacing = ''
msg = f'{str_key}{spacing} {msg}'
logger.spam(msg)
return msg
def handle_keycode(key_to_code, macros, event, uinput):
"""Write mapped keycodes, forward unmapped ones and manage macros.
@ -172,6 +192,9 @@ def handle_keycode(key_to_code, macros, event, uinput):
else:
# no subset found, just use the key. all indices are tuples of tuples,
# both for combinations and single keys.
if event.value == 1 and len(combination) > 1:
log(combination, 'unknown combination')
key = (key,)
active_macro = active_macros.get(type_code)
@ -183,15 +206,20 @@ def handle_keycode(key_to_code, macros, event, uinput):
# Tell the macro for that keycode that the key is released and
# let it decide what to do with that information.
active_macro.release_key()
logger.spam('%s, releasing macro', key)
log(key, 'releasing macro')
if type_code in unreleased:
target_type, target_code = unreleased[type_code][0]
logger.spam('%s, releasing %s', key, target_code)
del unreleased[type_code]
write(uinput, (target_type, target_code, 0))
if target_code == DISABLE_CODE:
log(key, 'releasing disabled key')
else:
log(key, 'releasing %s', target_code)
write(uinput, (target_type, target_code, 0))
else:
logger.spam('%s, unexpected key up', key)
# disabled keys can still be used in combinations btw
log(key, 'unexpected key up')
# everything that can be released is released now
return
@ -205,7 +233,7 @@ def handle_keycode(key_to_code, macros, event, uinput):
# This avoids spawning a second macro while the first one is not
# finished, especially since gamepad-triggers report a ton of
# events with a positive value.
logger.spam('%s, macro already running', key)
log(key, 'macro already running')
return
# it would write a key usually
@ -213,7 +241,7 @@ def handle_keycode(key_to_code, macros, event, uinput):
# duplicate key-down. skip this event. Avoid writing millions of
# key-down events when a continuous value is reported, for example
# for gamepad triggers
logger.spam('%s, duplicate key down', key)
log(key, 'duplicate key down')
return
"""starting new macros or injecting new keys"""
@ -223,20 +251,25 @@ def handle_keycode(key_to_code, macros, event, uinput):
macro = macros[key]
active_macros[type_code] = macro
macro.press_key()
logger.spam('%s, maps to macro %s', key, macro.code)
log(key, 'maps to macro %s', macro.code)
asyncio.ensure_future(macro.run())
return
if key in key_to_code:
target_code = key_to_code[key]
logger.spam('%s, maps to %s', key, target_code)
unreleased[type_code] = ((EV_KEY, target_code), event_tuple)
if target_code == DISABLE_CODE:
log(key, 'disabled')
return
log(key, 'maps to %s', target_code)
write(uinput, (EV_KEY, target_code, 1))
return
logger.spam('%s, forwarding', key)
log(key, 'forwarding')
unreleased[type_code] = ((event_tuple[:2]), event_tuple)
write(uinput, event_tuple)
return
logger.error('%s, unhandled. %s %s', key, unreleased, active_macros)
logger.error(key, '%s unhandled. %s %s', unreleased, active_macros)

@ -168,7 +168,6 @@ class Row(Gtk.ListBoxRow):
return
# it's legal to display the keycode
self.window.get('status_bar').remove_all(CTX_KEYCODE)
# always ask for get_child to set the label, otherwise line breaking
# has to be configured again.

@ -54,9 +54,6 @@ CTX_ERROR = 3
CTX_WARNING = 4
# TODO status warning for ctrl in key combis
def get_selected_row_bg():
"""Get the background color that a row is going to have when selected."""
# ListBoxRows can be selected, but either they are always selectable
@ -365,8 +362,13 @@ class Window:
if key is None:
return True
# only show the latest key, becomes too long otherwise
self.get('keycode').set_text(to_string(key).split('+')[-1].strip())
if key.is_problematic():
self.show_status(
CTX_WARNING,
'ctrl, alt and shift may not combine properly',
'Your system will probably reinterpret combinations with ' +
'those after they are injected, and by doing so break them.'
)
# inform the currently selected row about the new keycode
row, focused = self.get_focused_row()
@ -397,8 +399,8 @@ class Window:
if context_id == CTX_WARNING:
self.get('warning_status_icon').show()
if len(message) > 40:
message = message[:37] + '...'
if len(message) > 48:
message = message[:50] + '...'
status_bar = self.get('status_bar')
status_bar.push(context_id, message)

@ -24,7 +24,7 @@
import itertools
from keymapper.util import sign
from evdev import ecodes
def verify(key):
@ -35,6 +35,14 @@ def verify(key):
raise ValueError(f'Can only use numbers, but got {key}')
# having shift in combinations modifies the configured output,
# ctrl might not work at all
DIFFICULT_COMBINATIONS = [
ecodes.KEY_LEFTSHIFT, ecodes.KEY_RIGHTSHIFT,
ecodes.KEY_LEFTCTRL, ecodes.KEY_RIGHTCTRL,
ecodes.KEY_LEFTALT, ecodes.KEY_RIGHTALT
]
class Key:
"""Represents one or more pressed down keys.
@ -112,6 +120,20 @@ class Key:
# compare two instances of Key
return self.keys == other.keys
def is_problematic(self):
"""Is this combination going to work properly on all systems?"""
if len(self.keys) <= 1:
return False
for sub_key in self.keys:
if sub_key[0] != ecodes.EV_KEY:
continue
if sub_key[1] in DIFFICULT_COMBINATIONS:
return True
return False
def get_permutations(self):
"""Get a list of Key objects representing all possible permutations.

@ -32,6 +32,11 @@ from keymapper.config import ConfigBase, config
from keymapper.key import Key
DISABLE_NAME = 'disable'
DISABLE_CODE = -1
def split_key(key):
"""Take a key like "1,2,3" and return a 3-tuple of ints."""
key = key.strip()

@ -32,13 +32,13 @@ from keymapper.getdevices import get_devices
def migrate_path():
"""Migrate the folder structure from < 0.4.1.
"""Migrate the folder structure from < 0.4.0.
Move existing presets into the new subfolder "presets"
"""
new_preset_folder = os.path.join(CONFIG_PATH, 'presets')
if not os.path.exists(get_preset_path()) and os.path.exists(CONFIG_PATH):
logger.info('Migrating presets from < 0.4.1...')
logger.info('Migrating presets from < 0.4.0...')
devices = os.listdir(CONFIG_PATH)
mkdir(get_preset_path())
for device in devices:

@ -29,7 +29,7 @@ import subprocess
import evdev
from keymapper.logger import logger
from keymapper.mapping import Mapping
from keymapper.mapping import Mapping, DISABLE_NAME, DISABLE_CODE
from keymapper.paths import get_config_path, touch, USER
@ -95,6 +95,8 @@ class SystemMapping:
if name.startswith('KEY') or name.startswith('BTN'):
self._set(name, ecode)
self._set(DISABLE_NAME, DISABLE_CODE)
def update(self, mapping):
"""Update this with new keys.

@ -29,7 +29,7 @@ from evdev.ecodes import EV_REL, EV_KEY, EV_ABS, ABS_HAT0X
from keymapper.dev.injector import is_numlock_on, set_numlock, \
ensure_numlock, KeycodeInjector, is_in_capabilities
from keymapper.state import custom_mapping, system_mapping
from keymapper.mapping import Mapping
from keymapper.mapping import Mapping, DISABLE_CODE, DISABLE_NAME
from keymapper.config import config
from keymapper.key import Key
from keymapper.dev.macros import parse
@ -78,6 +78,7 @@ class TestInjector(unittest.TestCase):
mapping = Mapping()
mapping.change(Key(EV_KEY, 80, 1), 'a')
mapping.change(Key(EV_KEY, 81, 1), DISABLE_NAME)
macro_code = 'r(2, m(sHiFt_l, r(2, k(1).k(2))))'
macro = parse(macro_code, mapping)
@ -92,6 +93,7 @@ class TestInjector(unittest.TestCase):
shift_l = system_mapping.get('ShIfT_L')
one = system_mapping.get(1)
two = system_mapping.get('2')
btn_left = system_mapping.get('BtN_lEfT')
self.injector = KeycodeInjector('foo', mapping)
fake_device = FakeDevice()
@ -107,12 +109,16 @@ class TestInjector(unittest.TestCase):
self.assertIn(one, keys)
self.assertIn(two, keys)
self.assertIn(shift_l, keys)
self.assertNotIn(DISABLE_CODE, keys)
# abs_to_rel is false, so mouse capabilities are not needed
self.assertNotIn(btn_left, keys)
self.assertNotIn(evdev.ecodes.EV_SYN, capabilities_1)
self.assertNotIn(evdev.ecodes.EV_FF, capabilities_1)
self.assertNotIn(evdev.ecodes.EV_REL, capabilities_1)
self.assertNotIn(evdev.ecodes.EV_ABS, capabilities_1)
# abs_to_rel makes sure that BTN_LEFT is present
capabilities_2 = self.injector._modify_capabilities(
{60: macro},
fake_device,
@ -123,6 +129,7 @@ class TestInjector(unittest.TestCase):
self.assertIn(one, keys)
self.assertIn(two, keys)
self.assertIn(shift_l, keys)
self.assertIn(btn_left, keys)
def test_grab(self):
# path is from the fixtures

@ -25,7 +25,7 @@ import grp
import os
import unittest
import evdev
from evdev.ecodes import EV_KEY, EV_ABS, BTN_LEFT, BTN_TOOL_DOUBLETAP
from evdev.ecodes import EV_KEY, EV_ABS, KEY_LEFTSHIFT
import json
from unittest.mock import patch
from importlib.util import spec_from_loader, module_from_spec
@ -524,6 +524,12 @@ class TestIntegration(unittest.TestCase):
self.assertEqual(custom_mapping.get_character(combination_5), 'e')
self.assertEqual(custom_mapping.get_character(combination_6), 'e')
error_icon = self.window.get('error_status_icon')
warning_icon = self.window.get('warning_status_icon')
self.assertFalse(error_icon.get_visible())
self.assertFalse(warning_icon.get_visible())
def test_remove_row(self):
"""Comprehensive test for rows 2."""
# sleeps are added to be able to visually follow and debug the test.
@ -581,6 +587,19 @@ class TestIntegration(unittest.TestCase):
# of rows won't change.
remove(row_3, None, 'c', 1)
def test_problematic_combination(self):
combination = Key((EV_KEY, KEY_LEFTSHIFT, 1), (EV_KEY, 82, 1))
self.change_empty_row(combination, 'b')
status = self.window.get('status_bar')
text = status.get_message_area().get_children()[0].get_label()
self.assertIn('shift', text)
error_icon = self.window.get('error_status_icon')
warning_icon = self.window.get('warning_status_icon')
self.assertFalse(error_icon.get_visible())
self.assertTrue(warning_icon.get_visible())
def test_rename_and_save(self):
custom_mapping.change(Key(EV_KEY, 14, 1), 'a', None)
self.assertEqual(self.window.selected_preset, 'new preset')
@ -603,12 +622,14 @@ class TestIntegration(unittest.TestCase):
def test_check_macro_syntax(self):
status = self.window.get('status_bar')
error_icon = self.window.get('error_status_icon')
warning_icon = self.window.get('warning_status_icon')
custom_mapping.change(Key(EV_KEY, 9, 1), 'k(1))', None)
self.window.on_save_preset_clicked(None)
tooltip = status.get_tooltip_text().lower()
self.assertIn('brackets', tooltip)
self.assertTrue(error_icon.get_visible())
self.assertFalse(warning_icon.get_visible())
custom_mapping.change(Key(EV_KEY, 9, 1), 'k(1)', None)
self.window.on_save_preset_clicked(None)
@ -616,6 +637,7 @@ class TestIntegration(unittest.TestCase):
self.assertNotIn('brackets', tooltip)
self.assertIn('saved', tooltip)
self.assertFalse(error_icon.get_visible())
self.assertFalse(warning_icon.get_visible())
self.assertEqual(custom_mapping.get_character(Key(EV_KEY, 9, 1)), 'k(1)')

@ -0,0 +1,104 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2020 sezanzeb <proxima@hip70890b.de>
#
# This file is part of key-mapper.
#
# key-mapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# key-mapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
import unittest
from evdev.ecodes import KEY_LEFTSHIFT, KEY_RIGHTALT, KEY_LEFTCTRL
from keymapper.key import Key
class TestKey(unittest.TestCase):
def test_key(self):
# its very similar to regular tuples, but with some extra stuff
key_1 = Key((1, 3, 1), (1, 5, 1))
self.assertEqual(str(key_1), 'Key((1, 3, 1), (1, 5, 1))')
self.assertEqual(len(key_1), 2)
self.assertEqual(key_1[0], (1, 3, 1))
self.assertEqual(key_1[1], (1, 5, 1))
key_2 = Key((1, 3, 1))
self.assertEqual(str(key_2), 'Key((1, 3, 1),)')
self.assertEqual(len(key_2), 1)
self.assertNotEqual(key_2, key_1)
self.assertNotEqual(hash(key_2), hash(key_1))
key_3 = Key(1, 3, 1)
self.assertEqual(str(key_3), 'Key((1, 3, 1),)')
self.assertEqual(len(key_3), 1)
self.assertEqual(key_3, key_2)
self.assertEqual(key_3, (1, 3, 1))
self.assertEqual(hash(key_3), hash(key_2))
self.assertEqual(hash(key_3), hash((1, 3, 1)))
key_4 = Key(key_3)
self.assertEqual(str(key_4), 'Key((1, 3, 1),)')
self.assertEqual(len(key_4), 1)
self.assertEqual(key_4, key_3)
self.assertEqual(hash(key_4), hash(key_3))
key_5 = Key(key_4, key_4, (1, 7, 1))
self.assertEqual(str(key_5), 'Key((1, 3, 1), (1, 3, 1), (1, 7, 1))')
self.assertEqual(len(key_5), 3)
self.assertNotEqual(key_5, key_4)
self.assertNotEqual(hash(key_5), hash(key_4))
self.assertEqual(key_5, ((1, 3, 1), (1, 3, 1), (1, 7, 1)))
self.assertEqual(hash(key_5), hash(((1, 3, 1), (1, 3, 1), (1, 7, 1))))
def test_get_permutations(self):
key_1 = Key((1, 3, 1))
self.assertEqual(len(key_1.get_permutations()), 1)
self.assertEqual(key_1.get_permutations()[0], key_1)
key_2 = Key((1, 3, 1), (1, 5, 1))
self.assertEqual(len(key_2.get_permutations()), 1)
self.assertEqual(key_2.get_permutations()[0], key_2)
key_3 = Key((1, 3, 1), (1, 5, 1), (1, 7, 1))
self.assertEqual(len(key_3.get_permutations()), 2)
self.assertEqual(
key_3.get_permutations()[0],
Key((1, 3, 1), (1, 5, 1), (1, 7, 1))
)
self.assertEqual(
key_3.get_permutations()[1],
((1, 5, 1), (1, 3, 1), (1, 7, 1))
)
def test_is_problematic(self):
key_1 = Key((1, KEY_LEFTSHIFT, 1), (1, 5, 1))
self.assertTrue(key_1.is_problematic())
key_2 = Key((1, KEY_RIGHTALT, 1), (1, 5, 1))
self.assertTrue(key_2.is_problematic())
key_3 = Key((1, 3, 1), (1, KEY_LEFTCTRL, 1))
self.assertTrue(key_3.is_problematic())
key_4 = Key(1, 3, 1)
self.assertFalse(key_4.is_problematic())
key_5 = Key((1, 3, 1), (1, 5, 1))
self.assertFalse(key_5.is_problematic())
if __name__ == "__main__":
unittest.main()

@ -28,11 +28,11 @@ from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X, ABS_HAT0Y, KEY_A, ABS_X, \
EV_REL, REL_X, BTN_TL
from keymapper.dev.keycode_mapper import should_map_event_as_btn, \
active_macros, handle_keycode, unreleased, subsets
active_macros, handle_keycode, unreleased, subsets, log
from keymapper.state import system_mapping
from keymapper.dev.macros import parse
from keymapper.config import config
from keymapper.mapping import Mapping
from keymapper.mapping import Mapping, DISABLE_CODE
from tests.test import InputEvent, UInput, uinput_write_history, \
cleanup
@ -822,6 +822,90 @@ class TestKeycodeMapper(unittest.TestCase):
self.assertEqual(uinput_write_history[0].t, (EV_KEY, 21, 1))
self.assertEqual(uinput_write_history[1].t, (EV_KEY, 21, 0))
def test_ignore_disabled(self):
ev_1 = (EV_ABS, ABS_HAT0Y, 1)
ev_2 = (EV_ABS, ABS_HAT0Y, 0)
ev_3 = (EV_ABS, ABS_HAT0X, 1)
ev_4 = (EV_ABS, ABS_HAT0X, 0)
ev_5 = (EV_KEY, KEY_A, 1)
ev_6 = (EV_KEY, KEY_A, 0)
combi_1 = (ev_5, ev_3)
combi_2 = (ev_3, ev_5)
_key_to_code = {
(ev_1,): 61,
(ev_3,): DISABLE_CODE,
combi_1: 62,
combi_2: 63
}
uinput = UInput()
"""single keys"""
# down
handle_keycode(_key_to_code, {}, InputEvent(*ev_1), uinput)
handle_keycode(_key_to_code, {}, InputEvent(*ev_3), uinput)
# up
handle_keycode(_key_to_code, {}, InputEvent(*ev_2), uinput)
handle_keycode(_key_to_code, {}, InputEvent(*ev_4), uinput)
self.assertEqual(len(uinput_write_history), 2)
self.assertEqual(uinput_write_history[0].t, (EV_KEY, 61, 1))
self.assertEqual(uinput_write_history[1].t, (EV_KEY, 61, 0))
"""a combination that ends in a disabled key"""
# ev_5 should be forwarded and the combination triggered
handle_keycode(_key_to_code, {}, InputEvent(*combi_1[0]), uinput)
handle_keycode(_key_to_code, {}, InputEvent(*combi_1[1]), uinput)
self.assertEqual(len(uinput_write_history), 4)
self.assertEqual(uinput_write_history[2].t, (EV_KEY, KEY_A, 1))
self.assertEqual(uinput_write_history[3].t, (EV_KEY, 62, 1))
# release the last key of the combi first, it should
# release what the combination maps to
event = InputEvent(combi_1[1][0], combi_1[1][1], 0)
handle_keycode(_key_to_code, {}, event, uinput)
self.assertEqual(len(uinput_write_history), 5)
self.assertEqual(uinput_write_history[-1].t, (EV_KEY, 62, 0))
event = InputEvent(combi_1[0][0], combi_1[0][1], 0)
handle_keycode(_key_to_code, {}, event, uinput)
self.assertEqual(len(uinput_write_history), 6)
self.assertEqual(uinput_write_history[-1].t, (EV_KEY, KEY_A, 0))
"""a combination that starts with a disabled key"""
# only the combination should get triggered
handle_keycode(_key_to_code, {}, InputEvent(*combi_2[0]), uinput)
handle_keycode(_key_to_code, {}, InputEvent(*combi_2[1]), uinput)
self.assertEqual(len(uinput_write_history), 7)
self.assertEqual(uinput_write_history[-1].t, (EV_KEY, 63, 1))
# release the last key of the combi first, it should
# release what the combination maps to
event = InputEvent(combi_2[1][0], combi_2[1][1], 0)
handle_keycode(_key_to_code, {}, event, uinput)
self.assertEqual(len(uinput_write_history), 8)
self.assertEqual(uinput_write_history[-1].t, (EV_KEY, 63, 0))
# the first key of combi_2 is disabled, so it won't write another
# key-up event
event = InputEvent(combi_2[0][0], combi_2[0][1], 0)
handle_keycode(_key_to_code, {}, event, uinput)
self.assertEqual(len(uinput_write_history), 8)
def test_log(self):
msg1 = log(((1, 2, 1),), 'foo %s bar', 1234)
self.assertEqual(msg1, '((1, 2, 1)) ------------------- foo 1234 bar')
msg2 = log(((1, 200, -1), (1, 5, 1)), 'foo %s', (1, 2))
self.assertEqual(msg2, '((1, 200, -1), (1, 5, 1)) ----- foo (1, 2)')
if __name__ == "__main__":
unittest.main()

@ -63,6 +63,7 @@ class TestSystemMapping(unittest.TestCase):
# only xmodmap stuff should be present
self.assertNotIn('key_a', content)
self.assertNotIn('KEY_A', content)
self.assertNotIn('disable', content)
def test_system_mapping(self):
system_mapping = SystemMapping()
@ -102,6 +103,8 @@ class TestSystemMapping(unittest.TestCase):
self.assertIn('btn_left', names)
self.assertIn('btn_right', names)
self.assertEqual(system_mapping.get('disable'), -1)
class TestMapping(unittest.TestCase):
def setUp(self):

Loading…
Cancel
Save