diff --git a/data/key-mapper.glade b/data/key-mapper.glade index 6e2240d9..aae6ae8e 100644 --- a/data/key-mapper.glade +++ b/data/key-mapper.glade @@ -449,6 +449,7 @@ Don't hold down any keys while the injection starts. True True True + Hold down ctrl and click here to copy the current preset new-icon True diff --git a/keymapper/config.py b/keymapper/config.py index 74f79c8d..3c891c21 100644 --- a/keymapper/config.py +++ b/keymapper/config.py @@ -202,7 +202,7 @@ class GlobalConfig(ConfigBase): self.set(['autoload', device], preset) else: logger.info( - 'Not loading injecting for "%s" automatically anmore', + 'Not injecting for "%s" automatically anmore', device ) self.remove(['autoload', device]) diff --git a/keymapper/gui/window.py b/keymapper/gui/window.py index da506a60..1b7357a7 100755 --- a/keymapper/gui/window.py +++ b/keymapper/gui/window.py @@ -185,7 +185,7 @@ class Window: # now show the proper finished content of the window self.get('vertical-wrapper').set_opacity(1) - self.ctrl = 0 + self.ctrl = False self.unreleased_warn = 0 def unsaved_changes_dialog(self): @@ -465,11 +465,24 @@ class Window: try: self.save_preset() if new_name not in ['', self.selected_preset]: + # if a new name is entered rename_preset( self.selected_device, self.selected_preset, new_name ) + # if the old preset was being autoloaded, change the + # name there as well + is_autoloaded = config.is_autoloaded( + self.selected_device, + self.selected_preset + ) + if is_autoloaded: + config.set_autoload_preset( + self.selected_device, + new_name + ) + # after saving the config, its modification date will be the # newest, so populate_presets will automatically select the # right one again. @@ -630,9 +643,18 @@ class Window: if custom_mapping.changed and self.unsaved_changes_dialog() == GO_BACK: return + copy = self.ctrl + try: - new_preset = get_available_preset_name(self.selected_device) - custom_mapping.empty() + new_preset = get_available_preset_name( + self.selected_device, + self.selected_preset, + copy + ) + + if not copy: + custom_mapping.empty() + path = get_preset_path(self.selected_device, new_preset) custom_mapping.save(path) self.get('preset_selection').append(new_preset, new_preset) diff --git a/keymapper/mapping.py b/keymapper/mapping.py index 56ed319c..9ad819a9 100644 --- a/keymapper/mapping.py +++ b/keymapper/mapping.py @@ -115,10 +115,8 @@ class Mapping(ConfigBase): if character is None: raise ValueError('Expected `character` not to be None') - logger.debug( - '%s will map to "%s"', - new_key, character - ) + character = character.strip() + logger.debug('%s will map to "%s"', new_key, character) self.clear(new_key) # this also clears all equivalent keys self._mapping[new_key] = character diff --git a/keymapper/presets.py b/keymapper/presets.py index 158dcd1a..104f7008 100644 --- a/keymapper/presets.py +++ b/keymapper/presets.py @@ -25,6 +25,7 @@ import os import time import glob +import re from keymapper.paths import get_preset_path, mkdir, CONFIG_PATH from keymapper.logger import logger @@ -53,15 +54,27 @@ def migrate_path(): migrate_path() -def get_available_preset_name(device, preset='new preset'): +def get_available_preset_name(device, preset='new preset', copy=False): """Increment the preset name until it is available.""" preset = preset.strip() + if copy and not re.match(r'^.+\scopy( \d+)?$', preset): + preset = f'{preset} copy' + # find a name that is not already taken if os.path.exists(get_preset_path(device, preset)): - i = 2 + # if there already is a trailing number, increment it instead of + # adding another one + match = re.match(r'^(.+) (\d+)$', preset) + if match: + preset = match[1] + i = int(match[2]) + 1 + else: + i = 2 + while os.path.exists(get_preset_path(device, f'{preset} {i}')): i += 1 + return f'{preset} {i}' return preset diff --git a/readme/usage.md b/readme/usage.md index 5b0319fe..5949b2fc 100644 --- a/readme/usage.md +++ b/readme/usage.md @@ -92,6 +92,12 @@ delay of 10ms between key-down, key-up and at the end. See Bear in mind that anti-cheat software might detect macros in games. +## UI Shortcuts + +- Hold down `ctrl` and click on "new" to copy the current preset +- `shift` + `del` stops the injection (only works while the gui is in focus) +- `ctrl` + `q` closes the application + ## Key Names Check the autocompletion of the GUI for possible values. You can also diff --git a/tests/testcases/test_integration.py b/tests/testcases/test_integration.py index 8e416a83..4b9aade4 100644 --- a/tests/testcases/test_integration.py +++ b/tests/testcases/test_integration.py @@ -200,13 +200,14 @@ class TestIntegration(unittest.TestCase): [('device 1', 'new preset')] ) - # switch the preset, the switch should be correct and the config - # not changed. + # create a new preset, the switch should be correctly off and the + # config not changed. self.window.on_create_preset_clicked(None) gtk_iteration() self.assertEqual(self.window.selected_preset, 'new preset 2') self.assertFalse(self.window.get('preset_autoload_switch').get_active()) self.assertTrue(config.is_autoloaded('device 1', 'new preset')) + self.assertFalse(config.is_autoloaded('device 1', 'new preset 2')) # select a preset for the second device self.window.on_select_device(FakeDropdown('device 2')) @@ -355,6 +356,7 @@ class TestIntegration(unittest.TestCase): self.window.window.set_focus(row.keycode_input) gtk_iteration() + gtk_iteration() self.assertIsNone(row.get_key()) self.assertEqual(row.keycode_input.get_label(), 'press key') @@ -650,7 +652,6 @@ class TestIntegration(unittest.TestCase): 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 = self.get_status_text() self.assertIn('shift', text) @@ -661,10 +662,15 @@ class TestIntegration(unittest.TestCase): self.assertTrue(warning_icon.get_visible()) def test_rename_and_save(self): + self.assertEqual(self.window.selected_device, 'device 1') + self.assertFalse(config.is_autoloaded('device 1', 'new preset')) + custom_mapping.change(Key(EV_KEY, 14, 1), 'a', None) self.assertEqual(self.window.selected_preset, 'new preset') self.window.on_save_preset_clicked(None) self.assertEqual(custom_mapping.get_character(Key(EV_KEY, 14, 1)), 'a') + config.set_autoload_preset('device 1', 'new preset') + self.assertTrue(config.is_autoloaded('device 1', 'new preset')) custom_mapping.change(Key(EV_KEY, 14, 1), 'b', None) self.window.get('preset_name_input').set_text('asdf') @@ -672,6 +678,8 @@ class TestIntegration(unittest.TestCase): self.assertEqual(self.window.selected_preset, 'asdf') self.assertTrue(os.path.exists(f'{CONFIG_PATH}/presets/device 1/asdf.json')) self.assertEqual(custom_mapping.get_character(Key(EV_KEY, 14, 1)), 'b') + # after renaming the preset it is still set to autoload + self.assertTrue(config.is_autoloaded('device 1', 'asdf')) error_icon = self.window.get('error_status_icon') status = self.window.get('status_bar') @@ -743,6 +751,40 @@ class TestIntegration(unittest.TestCase): sorted(['abc 123.json', 'new preset 2.json']) ) + def test_copy_preset(self): + key_list = self.window.get('key_list') + self.change_empty_row(Key(EV_KEY, 81, 1), 'a') + time.sleep(0.1) + gtk_iteration() + self.window.on_save_preset_clicked(None) + self.assertEqual(len(key_list.get_children()), 2) + + self.window.ctrl = False + self.window.on_create_preset_clicked(None) + + # the preset should be empty, only one empty row present + self.assertEqual(len(key_list.get_children()), 1) + + # add one new row again + self.change_empty_row(Key(EV_KEY, 81, 1), 'b') + time.sleep(0.1) + gtk_iteration() + self.window.on_save_preset_clicked(None) + self.assertEqual(len(key_list.get_children()), 2) + + # this time it should be copied + self.window.ctrl = True + self.window.on_create_preset_clicked(None) + self.assertEqual(self.window.selected_preset, 'new preset 2 copy') + self.assertEqual(len(key_list.get_children()), 2) + self.assertEqual(key_list.get_children()[0].get_character(), 'b') + + # make another copy + self.window.on_create_preset_clicked(None) + self.assertEqual(self.window.selected_preset, 'new preset 2 copy 2') + self.assertEqual(len(key_list.get_children()), 2) + self.assertEqual(key_list.get_children()[0].get_character(), 'b') + def test_gamepad_config(self): # set some stuff in the beginning, otherwise gtk fails to # do handler_unblock_by_func, which makes no sense at all. diff --git a/tests/testcases/test_mapping.py b/tests/testcases/test_mapping.py index ae0ee38f..b8031cc0 100644 --- a/tests/testcases/test_mapping.py +++ b/tests/testcases/test_mapping.py @@ -138,7 +138,7 @@ class TestMapping(unittest.TestCase): # setting mapping.whatever does not overwrite the mapping # after saving. It should be ignored. - self.mapping.change(Key(EV_KEY, 81, 1), 'a') + self.mapping.change(Key(EV_KEY, 81, 1), ' a ') self.mapping.set('mapping.a', 2) self.assertEqual(self.mapping.num_saved_keys, 0) self.mapping.save(get_preset_path('foo', 'bar')) @@ -167,9 +167,9 @@ class TestMapping(unittest.TestCase): ev_2 = Key(EV_KEY, 2, 0) mapping1 = Mapping() - mapping1.change(ev_1, 'a') + mapping1.change(ev_1, ' a') mapping2 = mapping1.clone() - mapping1.change(ev_2, 'b') + mapping1.change(ev_2, 'b ') self.assertEqual(mapping1.get_character(ev_1), 'a') self.assertEqual(mapping1.get_character(ev_2), 'b') diff --git a/tests/testcases/test_presets.py b/tests/testcases/test_presets.py index 715dd550..c0e63e5f 100644 --- a/tests/testcases/test_presets.py +++ b/tests/testcases/test_presets.py @@ -42,6 +42,35 @@ def create_preset(device, name='new preset'): PRESETS = os.path.join(CONFIG_PATH, 'presets') +class TestPresets(unittest.TestCase): + def test_get_available_preset_name(self): + # no filename conflict + self.assertEqual(get_available_preset_name('_', 'qux 2'), 'qux 2') + + touch(get_preset_path('_', 'qux 5')) + self.assertEqual(get_available_preset_name('_', 'qux 5'), 'qux 6') + touch(get_preset_path('_', 'qux')) + self.assertEqual(get_available_preset_name('_', 'qux'), 'qux 2') + touch(get_preset_path('_', 'qux1')) + self.assertEqual(get_available_preset_name('_', 'qux1'), 'qux1 2') + touch(get_preset_path('_', 'qux 2 3')) + self.assertEqual(get_available_preset_name('_', 'qux 2 3'), 'qux 2 4') + + touch(get_preset_path('_', 'qux 5')) + self.assertEqual(get_available_preset_name('_', 'qux 5', True), 'qux 5 copy') + touch(get_preset_path('_', 'qux 5 copy')) + self.assertEqual(get_available_preset_name('_', 'qux 5', True), 'qux 5 copy 2') + touch(get_preset_path('_', 'qux 5 copy 2')) + self.assertEqual(get_available_preset_name('_', 'qux 5', True), 'qux 5 copy 3') + + touch(get_preset_path('_', 'qux 5copy')) + self.assertEqual(get_available_preset_name('_', 'qux 5copy', True), 'qux 5copy copy') + touch(get_preset_path('_', 'qux 5copy 2')) + self.assertEqual(get_available_preset_name('_', 'qux 5copy 2', True), 'qux 5copy 2 copy') + touch(get_preset_path('_', 'qux 5copy 2 copy')) + self.assertEqual(get_available_preset_name('_', 'qux 5copy 2 copy', True), 'qux 5copy 2 copy 2') + + class TestMigrate(unittest.TestCase): def test_migrate(self): if os.path.exists(tmp):