From 8d72d3c67b3006fb9a1329b3627231a0f04394ea Mon Sep 17 00:00:00 2001 From: Spencer Phillip Young Date: Fri, 7 Jul 2023 23:44:31 -0700 Subject: [PATCH 1/5] add methods for removing hotkeys and hotstrings --- ahk/_async/engine.py | 21 +++++++++++++++++++ ahk/_async/transport.py | 16 +++++++++++++++ ahk/_hotkey.py | 40 ++++++++++++++++++++++++++++++++++++ ahk/_sync/engine.py | 22 ++++++++++++++++++++ ahk/_sync/transport.py | 19 +++++++++++++++++ tests/_async/test_hotkeys.py | 22 +++++++++++++++++++- tests/_async/test_keys.py | 20 ++++++++++++++++++ tests/_sync/test_hotkeys.py | 22 +++++++++++++++++++- tests/_sync/test_keys.py | 20 ++++++++++++++++++ 9 files changed, 200 insertions(+), 2 deletions(-) diff --git a/ahk/_async/engine.py b/ahk/_async/engine.py index 634e13b4..eac26765 100644 --- a/ahk/_async/engine.py +++ b/ahk/_async/engine.py @@ -189,6 +189,27 @@ def add_hotstring( warnings.warn(warning.message, warning.category, stacklevel=2) return None + def remove_hotkey(self, keyname: str) -> None: + def _() -> None: + return None + + h = Hotkey(keyname=keyname, callback=_) # XXX: this can probably be avoided + self._transport.remove_hotkey(hotkey=h) + return None + + def clear_hotkeys(self) -> None: + self._transport.clear_hotkeys() + return None + + def remove_hotstring(self, trigger: str) -> None: + hs = Hotstring(trigger=trigger, replacement_or_callback='') # XXX: this can probably be avoided + self._transport.remove_hotstring(hs) + return None + + def clear_hotstrings(self) -> None: + self._transport.clear_hotstrings() + return None + async def set_title_match_mode(self, title_match_mode: TitleMatchMode, /) -> None: """ Sets the default title match mode diff --git a/ahk/_async/transport.py b/ahk/_async/transport.py index 528f8c1c..74027e21 100644 --- a/ahk/_async/transport.py +++ b/ahk/_async/transport.py @@ -362,6 +362,22 @@ def add_hotstring(self, hotstring: Hotstring) -> None: warnings.warn(warning.message, warning.category, stacklevel=2) return None + def remove_hotkey(self, hotkey: Hotkey) -> None: + self._hotkey_transport.remove_hotkey(hotkey) + return None + + def clear_hotkeys(self) -> None: + self._hotkey_transport.clear_hotkeys() + return None + + def remove_hotstring(self, hotstring: Hotstring) -> None: + self._hotkey_transport.remove_hotstring(hotstring) + return None + + def clear_hotstrings(self) -> None: + self._hotkey_transport.clear_hotstrings() + return None + def start_hotkeys(self) -> None: return self._hotkey_transport.start() diff --git a/ahk/_hotkey.py b/ahk/_hotkey.py index d730408c..40331d40 100644 --- a/ahk/_hotkey.py +++ b/ahk/_hotkey.py @@ -7,6 +7,7 @@ import subprocess import sys import threading +import time import warnings from abc import ABC from abc import abstractmethod @@ -102,6 +103,38 @@ def add_hotstring(self, hotstring: Hotstring) -> None: # TODO: add support for adding IfWinActive/IfWinExist return None + def remove_hotkey(self, hotkey: Hotkey) -> None: + if hotkey._id not in self._callback_registry: + raise ValueError(f'Hotkey {hotkey.keyname!r} is not registered') + del self._hotkeys[hotkey._id] + self._get_callback_registry.cache_clear() + if self._running: + self.restart() + return None + + def clear_hotkeys(self) -> None: + self._hotkeys.clear() + self._get_callback_registry.cache_clear() + if self._running: + self.restart() + return None + + def remove_hotstring(self, hotstring: Hotstring) -> None: + if hotstring._id not in self._callback_registry: + raise ValueError(f'Hostring {hotstring.trigger!r} is not registered') + del self._hotstrings[hotstring._id] + self._get_callback_registry.cache_clear() + if self._running: + self.restart() + return None + + def clear_hotstrings(self) -> None: + self._hotstrings.clear() + self._get_callback_registry.cache_clear() + if self._running: + self.restart() + return None + def on_clipboard_change( self, callback: Callable[[int], Any], ex_handler: Optional[Callable[[int, Exception], Any]] = None ) -> None: @@ -170,6 +203,13 @@ def start(self) -> None: dispatcher_thread.start() def stop(self) -> None: + assert self._running is True, 'Not running! Must be started first!' + assert self._dispatcher_thread is not None + for i in range(1, 6): + if self._proc is not None: + break + logging.debug(f'stop called before dispatched has started proc. Waiting for proc ({i}/5)') + time.sleep(0.2) assert self._proc is not None self._running = False diff --git a/ahk/_sync/engine.py b/ahk/_sync/engine.py index a4c12d38..bd82bfab 100644 --- a/ahk/_sync/engine.py +++ b/ahk/_sync/engine.py @@ -185,6 +185,27 @@ def add_hotstring( warnings.warn(warning.message, warning.category, stacklevel=2) return None + def remove_hotkey(self, keyname: str) -> None: + def _() -> None: + return None + h = Hotkey(keyname=keyname, callback=_) # XXX: this can probably be avoided + self._transport.remove_hotkey(hotkey=h) + return None + + def clear_hotkeys(self) -> None: + self._transport.clear_hotkeys() + return None + + def remove_hotstring(self, trigger: str) -> None: + hs = Hotstring(trigger=trigger, replacement_or_callback='') # XXX: this can probably be avoided + self._transport.remove_hotstring(hs) + return None + + def clear_hotstrings(self) -> None: + self._transport.clear_hotstrings() + return None + + def set_title_match_mode(self, title_match_mode: TitleMatchMode, /) -> None: """ Sets the default title match mode @@ -2693,6 +2714,7 @@ def image_search( if coord_mode is not None: args.append(coord_mode) + resp = self._transport.function_call('AHKImageSearch', args, blocking=blocking) return resp diff --git a/ahk/_sync/transport.py b/ahk/_sync/transport.py index 3d688401..8b2ede27 100644 --- a/ahk/_sync/transport.py +++ b/ahk/_sync/transport.py @@ -343,6 +343,25 @@ def add_hotstring(self, hotstring: Hotstring) -> None: warnings.warn(warning.message, warning.category, stacklevel=2) return None + def remove_hotkey(self, hotkey: Hotkey) -> None: + self._hotkey_transport.remove_hotkey(hotkey) + return None + + def clear_hotkeys(self) -> None: + self._hotkey_transport.clear_hotkeys() + return None + + def remove_hotstring(self, hotstring: Hotstring) -> None: + self._hotkey_transport.remove_hotstring(hotstring) + return None + + def clear_hotstrings(self) -> None: + self._hotkey_transport.clear_hotstrings() + return None + + + + def start_hotkeys(self) -> None: return self._hotkey_transport.start() diff --git a/tests/_async/test_hotkeys.py b/tests/_async/test_hotkeys.py index 6e2b618a..804252e9 100644 --- a/tests/_async/test_hotkeys.py +++ b/tests/_async/test_hotkeys.py @@ -14,7 +14,7 @@ sleep = time.sleep -class TestMouseAsync(IsolatedAsyncioTestCase): +class TestHotkeysAsync(IsolatedAsyncioTestCase): win: AsyncWindow async def asyncSetUp(self) -> None: @@ -47,3 +47,23 @@ def side_effect(): await self.ahk.key_press('a') await async_sleep(1) mock_ex_handler.assert_called() + + async def test_remove_hotkey(self): + with mock.MagicMock(return_value=None) as m: + self.ahk.add_hotkey('a', callback=m) + self.ahk.start_hotkeys() + self.ahk.remove_hotkey('a') + await self.ahk.key_down('a') + await self.ahk.key_press('a') + await async_sleep(1) + m.assert_not_called() + + async def test_clear_hotkeys(self): + with mock.MagicMock(return_value=None) as m: + self.ahk.add_hotkey('a', callback=m) + self.ahk.start_hotkeys() + self.ahk.clear_hotkeys() + await self.ahk.key_down('a') + await self.ahk.key_press('a') + await async_sleep(1) + m.assert_not_called() diff --git a/tests/_async/test_keys.py b/tests/_async/test_keys.py index 87650e02..7fa44c40 100644 --- a/tests/_async/test_keys.py +++ b/tests/_async/test_keys.py @@ -48,6 +48,26 @@ async def test_hotstring(self): assert 'by the way' in await self.win.get_text() + async def test_remove_hotstring(self): + self.ahk.add_hotstring('btw', 'by the way') + self.ahk.start_hotkeys() + await self.ahk.set_send_level(1) + await self.win.activate() + self.ahk.remove_hotstring('btw') + await self.ahk.send('btw ') + time.sleep(2) + assert 'by the way' not in await self.win.get_text() + + async def test_clear_hotstrings(self): + self.ahk.add_hotstring('btw', 'by the way') + self.ahk.start_hotkeys() + await self.ahk.set_send_level(1) + await self.win.activate() + self.ahk.clear_hotstrings() + await self.ahk.send('btw ') + time.sleep(2) + assert 'by the way' not in await self.win.get_text() + async def test_hotstring_callback(self): with unittest.mock.MagicMock(return_value=None) as m: self.ahk.add_hotstring('btw', m) diff --git a/tests/_sync/test_hotkeys.py b/tests/_sync/test_hotkeys.py index d0cece8f..ec13a58d 100644 --- a/tests/_sync/test_hotkeys.py +++ b/tests/_sync/test_hotkeys.py @@ -11,7 +11,7 @@ sleep = time.sleep -class TestMouseAsync(TestCase): +class TestHotkeysAsync(TestCase): win: Window def setUp(self) -> None: @@ -44,3 +44,23 @@ def side_effect(): self.ahk.key_press('a') sleep(1) mock_ex_handler.assert_called() + + def test_remove_hotkey(self): + with mock.MagicMock(return_value=None) as m: + self.ahk.add_hotkey('a', callback=m) + self.ahk.start_hotkeys() + self.ahk.remove_hotkey('a') + self.ahk.key_down('a') + self.ahk.key_press('a') + sleep(1) + m.assert_not_called() + + def test_clear_hotkeys(self): + with mock.MagicMock(return_value=None) as m: + self.ahk.add_hotkey('a', callback=m) + self.ahk.start_hotkeys() + self.ahk.clear_hotkeys() + self.ahk.key_down('a') + self.ahk.key_press('a') + sleep(1) + m.assert_not_called() diff --git a/tests/_sync/test_keys.py b/tests/_sync/test_keys.py index b4be92af..f8269404 100644 --- a/tests/_sync/test_keys.py +++ b/tests/_sync/test_keys.py @@ -47,6 +47,26 @@ def test_hotstring(self): assert 'by the way' in self.win.get_text() + def test_remove_hotstring(self): + self.ahk.add_hotstring('btw', 'by the way') + self.ahk.start_hotkeys() + self.ahk.set_send_level(1) + self.win.activate() + self.ahk.remove_hotstring('btw') + self.ahk.send('btw ') + time.sleep(2) + assert 'by the way' not in self.win.get_text() + + def test_clear_hotstrings(self): + self.ahk.add_hotstring('btw', 'by the way') + self.ahk.start_hotkeys() + self.ahk.set_send_level(1) + self.win.activate() + self.ahk.clear_hotstrings() + self.ahk.send('btw ') + time.sleep(2) + assert 'by the way' not in self.win.get_text() + def test_hotstring_callback(self): with unittest.mock.MagicMock(return_value=None) as m: self.ahk.add_hotstring('btw', m) From cf375073cb4e1ed99958236f44afa2df52be91ea Mon Sep 17 00:00:00 2001 From: Spencer Phillip Young Date: Fri, 7 Jul 2023 23:55:25 -0700 Subject: [PATCH 2/5] update readme for new hotstring and hotkey methods --- docs/README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index ac20c4b7..ebc4b078 100644 --- a/docs/README.md +++ b/docs/README.md @@ -72,12 +72,20 @@ def my_ex_handler(hotkey: str, exception: Exception): ahk.add_hotkey('#n', callback=go_boom, ex_handler=my_ex_handler) ``` +There are also methods for removing hotkeys: + +```python +# ... +ahk.remove_hotkey('#n') # remove a hotkey by its keyname +ahk.clear_hotkeys() # remove all hotkeys +``` + Note that: - Hotkeys run in a separate process that must be started manually (with `ahk.start_hotkeys()`) - Hotkeys can be stopped with `ahk.stop_hotkeys()` (will not stop actively running callbacks) - Hotstrings (discussed below) share the same process with hotkeys and are started/stopped in the same manner -- If hotkeys or hotstrings are added while the process is running, the underlying AHK process is restarted automatically +- If hotkeys or hotstrings are added or removed while the process is running, the underlying AHK process is restarted automatically See also the [relevant AHK documentation](https://www.autohotkey.com/docs/Hotkeys.htm) @@ -100,6 +108,13 @@ ahk.add_hotstring('btw', 'by the way') # string replacements ahk.add_hotstring('btw', my_callback) # call python function in response to the hotstring ``` +You can also remove hotstrings: + +```python +ahk.remove_hotstring('btw') # remove hotkey by the trigger sequence +ahk.clear_hotstrings() # remove all registered hotstrings +``` + ## Mouse ```python From 660a6a57be748ec1cbc451aa63e3f6f7a8ad214f Mon Sep 17 00:00:00 2001 From: Spencer Phillip Young Date: Fri, 7 Jul 2023 23:55:49 -0700 Subject: [PATCH 3/5] prepare 1.2.0rc1 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index cba7ea43..de24d970 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = ahk -version = 1.1.3 +version = 1.2.0rc1 author_email = spencer.young@spyoung.com author = Spencer Young description = A Python wrapper for AHK From dc3b4bdef58f837143c3644cfdb08962065b037a Mon Sep 17 00:00:00 2001 From: Spencer Phillip Young Date: Sat, 8 Jul 2023 00:04:31 -0700 Subject: [PATCH 4/5] fix typo --- docs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index ebc4b078..59d7b586 100644 --- a/docs/README.md +++ b/docs/README.md @@ -111,7 +111,7 @@ ahk.add_hotstring('btw', my_callback) # call python function in response to the You can also remove hotstrings: ```python -ahk.remove_hotstring('btw') # remove hotkey by the trigger sequence +ahk.remove_hotstring('btw') # remove a hotstring by its trigger sequence ahk.clear_hotstrings() # remove all registered hotstrings ``` From be53d46afd6451d6a4070a828b93ed7b3717ebd5 Mon Sep 17 00:00:00 2001 From: Spencer Phillip Young Date: Sat, 8 Jul 2023 09:49:25 -0700 Subject: [PATCH 5/5] improve stop hotkey performance --- ahk/_hotkey.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ahk/_hotkey.py b/ahk/_hotkey.py index 40331d40..7cb076a4 100644 --- a/ahk/_hotkey.py +++ b/ahk/_hotkey.py @@ -205,11 +205,11 @@ def start(self) -> None: def stop(self) -> None: assert self._running is True, 'Not running! Must be started first!' assert self._dispatcher_thread is not None - for i in range(1, 6): + for i in range(1, 11): if self._proc is not None: break - logging.debug(f'stop called before dispatched has started proc. Waiting for proc ({i}/5)') - time.sleep(0.2) + logging.debug(f'stop called before dispatched has started proc. Waiting for proc ({i}/10)') + time.sleep(0.1) assert self._proc is not None self._running = False