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):