├── addon ├── VERSION ├── __init__.py ├── manifest.json ├── web │ └── detect_wheel.js ├── compat │ ├── __init__.py │ └── v1.py ├── config.json ├── config.md ├── firstrun.py ├── config.py └── event.py ├── mypy.ini ├── .gitmodules ├── tests ├── test_config.py ├── conftest.py └── test_compat_v1.py ├── .github └── workflows │ ├── checks.yml │ └── create_release.yml ├── new_version.py ├── LICENSE ├── README.md ├── DESCRIPTION.html └── .gitignore /addon/VERSION: -------------------------------------------------------------------------------- 1 | 2.14 -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | no_strict_optional = True 3 | disallow_untyped_defs = True -------------------------------------------------------------------------------- /addon/__init__.py: -------------------------------------------------------------------------------- 1 | from . import firstrun 2 | from . import config 3 | from . import event 4 | -------------------------------------------------------------------------------- /addon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "package": "review-hotmouse", 3 | "name": "Review Hotmouse", 4 | "human_version": "2.14" 5 | } -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "addon/ankiaddonconfig"] 2 | path = addon/ankiaddonconfig 3 | url = https://github.com/BlueGreenMagick/ankiaddonconfig.git 4 | -------------------------------------------------------------------------------- /addon/web/detect_wheel.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("wheel", (ev) => { 2 | let req = { 3 | "key": "wheel", 4 | "value": ev.deltaY 5 | } 6 | let req_str = JSON.stringify(req) 7 | let resp = pycmd("ReviewHotmouse#" + req_str) 8 | if (resp) { 9 | ev.preventDefault() 10 | ev.stopPropagation() 11 | } 12 | }) 13 | 14 | 15 | -------------------------------------------------------------------------------- /addon/compat/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from .v1 import v1_compat 3 | 4 | if TYPE_CHECKING: 5 | from ..firstrun import Version 6 | 7 | 8 | def compat(prev_version: "Version") -> None: 9 | """Executes code for compatability from older versions.""" 10 | if prev_version == "-1.-1": 11 | return 12 | elif prev_version < "2.0": 13 | print("Review Hotmouse: Running v1_compat()") 14 | v1_compat() 15 | -------------------------------------------------------------------------------- /addon/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "shortcuts": { 3 | "q_wheel_down": "show_ans", 4 | "q_press_left_click_right": "off", 5 | "a_press_left_click_right": "off", 6 | "a_click_right": "undo", 7 | "q_click_right": "undo", 8 | "a_wheel_up": "again", 9 | "a_wheel_down": "good", 10 | "a_press_middle_wheel_up": "hard", 11 | "a_press_middle_wheel_down": "easy" 12 | }, 13 | "default_enabled": true, 14 | "threshold_wheel_ms": 350, 15 | "tooltip": false, 16 | "z_debug": false, 17 | "version": { 18 | "major": -1, 19 | "minor": -1 20 | } 21 | } -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | def test_sort_hotkey_btn() -> None: 2 | from addon.config import HotkeyTabManager 3 | 4 | h0 = "q_click_right" 5 | a0 = h0 6 | assert HotkeyTabManager.sort_hotkey_btn(h0) == a0 7 | h1 = "q_press_right_press_left_click_middle" 8 | a1 = "q_press_left_press_right_click_middle" 9 | assert HotkeyTabManager.sort_hotkey_btn(h1) == a1 10 | h2 = "a_press_xbutton1_press_right_press_left_wheel_up" 11 | a2 = "a_press_left_press_right_press_xbutton1_wheel_up" 12 | assert HotkeyTabManager.sort_hotkey_btn(h2) == a2 13 | h3 = "q_press_right_click_left" 14 | a3 = h3 15 | assert HotkeyTabManager.sort_hotkey_btn(h3) == a3 16 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | with: 17 | submodules: recursive 18 | 19 | - uses: actions/setup-python@v3 20 | with: 21 | python-version: "3.9" 22 | 23 | - name: Install Python dependencies 24 | run: python -m pip install aqt mypy black pytest PyQt6 PyQt6-WebEngine 25 | 26 | - name: Run black 27 | run: black --check . 28 | 29 | - name: Run mypy 30 | run: mypy --install-types --non-interactive addon/ 31 | -------------------------------------------------------------------------------- /new_version.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import simplejson 4 | from pathlib import Path 5 | 6 | version_string = sys.argv[1] 7 | assert re.match(r"^(\d+).(\d+)$", version_string) 8 | addon_root = Path(sys.argv[2]) 9 | assert addon_root.is_dir() 10 | 11 | 12 | manifest_path = addon_root / "manifest.json" 13 | # Write version in manifest.json 14 | with manifest_path.open("r") as f: 15 | manifest = simplejson.load(f) 16 | 17 | with manifest_path.open("w") as f: 18 | manifest["human_version"] = version_string 19 | simplejson.dump(manifest, f, indent=2) 20 | 21 | # human_version is only updated on install. 22 | # For developing purposes, use VERSION file to check current version 23 | version_path = addon_root / "VERSION" 24 | version_path.write_text(version_string) 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Yoonchae Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | For add-on description, please see [AnkiWeb page](https://ankiweb.net/shared/info/1928346827) 2 | 3 | # Development 4 | 5 | ## Setup 6 | 7 | After cloning the project, run the following command to install [ankiaddonconfig](https://github.com/BlueGreenMagick/ankiaddonconfig/) as a git submodule. 8 | 9 | ``` 10 | git submodule update --init --recursive 11 | ``` 12 | 13 | ## Tests & Formatting 14 | 15 | This project uses [black], [mypy](https://github.com/python/mypy), and [pytest]. 16 | 17 | ```shell 18 | black . 19 | mypy . 20 | # pytest . (pytest no longer works) 21 | ``` 22 | 23 | You will need to install the following python packages to run black, mypy and pytest 24 | 25 | ``` 26 | pip install aqt PyQt6 mypy black pytest 27 | ``` 28 | 29 | You may need to uninstall `pyqt5-stubs` for mypy to work correctly. 30 | 31 | # Building ankiaddon file 32 | 33 | After cloning the repo, go into the repo directory and run the following command to install the git submodule [ankiaddonconfig](https://github.com/BlueGreenMagick/ankiaddonconfig/) 34 | 35 | ``` 36 | git submodule update --init --remote addon/ankiaddonconfig 37 | ``` 38 | 39 | After installing the git submodule, run the following command to create an `review_hotmouse.ankiaddon` file 40 | 41 | ``` 42 | cd addon ; zip -r ../review_hotmouse.ankiaddon * ; cd ../ 43 | ``` 44 | -------------------------------------------------------------------------------- /addon/config.md: -------------------------------------------------------------------------------- 1 | **General Config** 2 | 3 | - `tooltip`[true/false]: Show action when shortcut is triggered 4 | - `z_debug`[true/false]: Show hotkey on mouse action. 5 | 6 | **Card side** 7 | 8 | - `q` : Works when viewing question 9 | - `a` : Works when viewing answers 10 | 11 | **mouse buttons (press/click)** 12 | 13 | - `left` 14 | - `right` 15 | - `middle` 16 | - `xbutton1` 17 | - `xbutton2` 18 | 19 | **direction (wheel)** 20 | 21 | - `up` 22 | - `down` 23 | 24 | **input types** 25 | 26 | - `press` : Buttons being pressed when triggered. 27 | - `click` : Trigger shortcut when this button is pressed. 28 | - `wheel` : Scrolling inputs. 29 | 30 | **Shortcut Syntax**: 31 | 32 | - \[q/a\]\_\[click/wheel\]\_\[button/direction\] 33 | - \[q/a\]\_\[press\]\_\[button\]\_\[click/wheel\]\_\[button/direction\] 34 | - \[q/a\]_\[press\]\_\[button\]\_\[press\]\_\[button\]\_\[click/wheel\]\_\[button/direction\] 35 | - etc. 36 | 37 | When shortcut has multiple `press\_button`s, the buttons must be in the same order as listed under *mouse_buttons*. 38 | 39 | 40 | **action** 41 | 42 | - ``: Does nothing 43 | - `undo` 44 | - `off` 45 | - `on` 46 | - `on_off` 47 | - `again` 48 | - `hard` 49 | - `good` 50 | - `easy` 51 | - `delete` 52 | - `suspend_card` 53 | - `suspend_note` 54 | - `bury_card` 55 | - `bury_note` 56 | - `mark` 57 | - `red` 58 | - `orange` 59 | - `green` 60 | - `blue` 61 | - `audio` : replay audio 62 | - `record_voice` 63 | - `replay_voice` 64 | - `replay_audio` 65 | - `toggle_auto_advance` 66 | - `copy` 67 | - `paste` 68 | -------------------------------------------------------------------------------- /.github/workflows/create_release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: version string (eg. 6.1) 8 | required: true 9 | 10 | jobs: 11 | create-release: 12 | name: Version ${{ github.event.inputs.version }} 13 | runs-on: ubuntu-latest 14 | env: 15 | ADDON_NAME: Review Hotmouse 16 | # Path of files to put inside addon root when creating addon file. Whitespace separated. 17 | BUNDLE_FILES: LICENSE README.md 18 | # Path of addon root in repo. 19 | ADDON_ROOT: addon 20 | ADDON_FILE_NAME: review_hotmouse_v${{ github.event.inputs.version }}.ankiaddon 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | with: 25 | submodules: recursive 26 | 27 | - name: Setup Python 28 | uses: actions/setup-python@v2.2.1 29 | with: 30 | python-version: 3.8 31 | 32 | - name: Install dependencies 33 | run: python -m pip install simplejson 34 | 35 | - name: Run new_version.py 36 | run: python new_version.py ${{ github.event.inputs.version }} ${{ env.ADDON_ROOT }} 37 | 38 | - name: Commit and push changes to git 39 | uses: EndBug/add-and-commit@v7.1.1 40 | with: 41 | message: Bump version to v${{ github.event.inputs.version }} 42 | 43 | - name: Bundle files into addon root 44 | run: cp ${{ env.BUNDLE_FILES }} ${{ env.ADDON_ROOT }} 45 | 46 | - name: Create ankiaddon file 47 | run: | 48 | base_path="$PWD" 49 | cd "${{ env.ADDON_ROOT }}" 50 | zip -r "$base_path/${{ env.ADDON_FILE_NAME }}" * 51 | cd "$base_path" 52 | 53 | - name: Create github release and upload ankiaddon file 54 | uses: svenstaro/upload-release-action@2.9.0 55 | with: 56 | repo_token: ${{ github.token }} 57 | file: ${{ env.ADDON_FILE_NAME }} 58 | tag: ${{ github.event.inputs.version }} 59 | release_name: ${{ env.ADDON_NAME }} v${{ github.event.inputs.version }} 60 | -------------------------------------------------------------------------------- /DESCRIPTION.html: -------------------------------------------------------------------------------- 1 |   2 | With Review Hotmouse, use mouse shortcuts to review easily and quickly. 3 | 4 | For example, Scroll down to show answer, scroll up to answer 'again'. 5 | 6 | How to use 7 | 8 | By default, the hotmouses are configured as below. 9 | You can customize the hotkey and their actions in the add-on config. 10 | The shortcuts will only work while reviewing cards. 11 | 12 | right click: undo 13 | scroll down (front): show answer 14 | scroll up (back): again 15 | scroll down (back): good 16 | middle button + scroll up (back): hard 17 | middle button + scroll down (back): easy 18 | left click + right click (left click first): disable hotkey 19 | 20 | How It Works & Customizing Hotkeys 21 | 22 | A hotkey is comprised of mouse press and mouse wheel events. 23 | 24 | When you press a mouse button, the add-on searches for a hotkey where all the buttons you pressed beforehand are 25 | written as 'press [button name]' and the last button you pressed as 'click [button name]'. 26 | The same thing happens when you scroll: it searches for a hotkey where all the buttons that are being pressed 27 | are written as 'press [button name]' and the scroll direction as 'wheel [direction]'. 28 | If such hotkey exists, its corresponding action is executed. 29 | 30 | Notes 31 | If you have many long cards that require scrolling, having scroll hotkeys will not let you scroll your card 32 | unless you turn off the add-on. (Left click + Right click by default) 33 | 34 | You will not be able to access the context menu while reviewing unless you turn off the add-on. 35 | 36 | This add-on pairs well with Audiovisual Feedback add-on. Try it out! 37 | 38 | Additional Links 39 | 40 | Ankiweb - Github - Report an issue - License 41 | 42 | This addon was created by Bluegreenmagick, and is licensed under the MIT License. -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Dict, Any 2 | from pathlib import Path 3 | from unittest.mock import Mock 4 | import pytest 5 | import json 6 | import sys 7 | 8 | import aqt 9 | import aqt.utils 10 | from aqt.addons import AddonMeta 11 | 12 | project_path = Path(__file__).parent.parent 13 | addon_path = project_path / "addon" 14 | sys.path.append(str(project_path)) 15 | 16 | 17 | class MockAddonManager: 18 | def __init__(self) -> None: 19 | config_path = addon_path / "config.json" 20 | manifest_path = addon_path / "manifest.json" 21 | config_json_str = config_path.read_text() 22 | self.default_config = json.loads(config_json_str) 23 | self.config = json.loads(config_json_str) # seperate dict obj 24 | self.meta = json.loads(manifest_path.read_text()) 25 | self.meta["config"] = self.config 26 | 27 | def getConfig(self, module: str) -> Dict[str, Any]: 28 | return self.config 29 | 30 | def writeConfig(self, module: str, data: Dict[str, Any]) -> None: 31 | self.config = data 32 | 33 | def addonFromModule(self, module: str) -> str: 34 | return module.split(".")[0] 35 | 36 | def addonMeta(self, module: str) -> Dict[str, Any]: 37 | self.meta["config"] = self.config 38 | return self.meta 39 | 40 | def addon_meta(self, module: str) -> AddonMeta: 41 | return AddonMeta.from_json_meta(module, self.addonMeta(module)) 42 | 43 | def addonConfigDefaults(self, module: str) -> Dict[str, Any]: 44 | return self.default_config 45 | 46 | def setConfigAction(self, module: str, func: Callable) -> None: 47 | return None 48 | 49 | def setWebExports(self, module: str, pattern: str) -> None: 50 | return None 51 | 52 | 53 | @pytest.fixture(autouse=True) 54 | def mock_addonmanager(monkeypatch: Any) -> None: 55 | """Mock mw.addonManager""" 56 | mw = Mock() 57 | mw.configure_mock(addonManager=MockAddonManager()) 58 | monkeypatch.setattr(aqt, "mw", mw) 59 | 60 | 61 | @pytest.fixture(autouse=True) 62 | def dont_show_aqt_utils_gui() -> None: 63 | for fn_name in ("showText", "showInfo"): 64 | setattr(aqt.utils, fn_name, lambda *_, **__: None) 65 | -------------------------------------------------------------------------------- /addon/firstrun.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | from pathlib import Path 3 | import os 4 | 5 | from aqt import mw 6 | 7 | from .compat import compat 8 | 9 | config = mw.addonManager.getConfig(__name__) 10 | 11 | 12 | class Version: 13 | @classmethod 14 | def from_string(cls: Type["Version"], ver_str: str) -> "Version": 15 | ver = [int(i) for i in ver_str.split(".")] 16 | version = Version(from_config=False) 17 | version.set_version(ver[0], ver[1]) 18 | return version 19 | 20 | def __init__(self, from_config: bool = True) -> None: 21 | if from_config: 22 | self.load() 23 | 24 | def load(self) -> None: 25 | self.set_version(config["version"]["major"], config["version"]["minor"]) 26 | 27 | def set_version(self, major: int, minor: int) -> None: 28 | self.major = major 29 | self.minor = minor 30 | 31 | def __eq__(self, other: str) -> bool: # type: ignore 32 | ver = [int(i) for i in other.split(".")] 33 | return self.major == ver[0] and self.minor == ver[1] 34 | 35 | def __gt__(self, other: str) -> bool: 36 | ver = [int(i) for i in other.split(".")] 37 | return self.major > ver[0] or (self.major == ver[0] and self.minor > ver[1]) 38 | 39 | def __lt__(self, other: str) -> bool: 40 | ver = [int(i) for i in other.split(".")] 41 | return self.major < ver[0] or (self.major == ver[0] and self.minor < ver[1]) 42 | 43 | def __ge__(self, other: str) -> bool: 44 | return self == other or self > other 45 | 46 | def __le__(self, other: str) -> bool: 47 | return self == other or self < other 48 | 49 | 50 | def save_current_version_to_conf() -> None: 51 | # For debugging 52 | version_string = os.environ.get("REVIEW_HOTMOUSE_VERSION") 53 | if not version_string: 54 | version_file = Path(__file__).parent / "VERSION" 55 | version_string = version_file.read_text() 56 | if version_string != prev_version: 57 | config["version"]["major"] = int(version_string.split(".")[0]) 58 | config["version"]["minor"] = int(version_string.split(".")[1]) 59 | mw.addonManager.writeConfig(__name__, config) 60 | 61 | 62 | def detect_version() -> Version: 63 | """Approximately detects previous version when the add-on didn't store 'version' in config.""" 64 | if "threshold_angle" in config: 65 | return Version.from_string("1.0") 66 | if "q_wheel_down" in config: 67 | return Version.from_string("1.1") # v1.1 ~ 1.5 68 | else: 69 | return Version.from_string("-1.-1") 70 | 71 | 72 | # version of the add-on prior to running this script 73 | prev_version = Version() 74 | 75 | save_current_version_to_conf() 76 | 77 | if prev_version == "-1.-1": 78 | prev_version = detect_version() 79 | 80 | compat(prev_version) 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Anki related 2 | meta.json 3 | 4 | run 5 | /venv 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | cover/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | .pybuilder/ 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | # For a library or package, you might want to ignore these files since the code is 93 | # intended to run in multiple environments; otherwise, check them in: 94 | # .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 104 | __pypackages__/ 105 | 106 | # Celery stuff 107 | celerybeat-schedule 108 | celerybeat.pid 109 | 110 | # SageMath parsed files 111 | *.sage.py 112 | 113 | # Environments 114 | .env 115 | .venv 116 | env/ 117 | venv/ 118 | ENV/ 119 | env.bak/ 120 | venv.bak/ 121 | 122 | # Spyder project settings 123 | .spyderproject 124 | .spyproject 125 | 126 | # Rope project settings 127 | .ropeproject 128 | 129 | # mkdocs documentation 130 | /site 131 | 132 | # mypy 133 | .mypy_cache/ 134 | .dmypy.json 135 | dmypy.json 136 | 137 | # Pyre type checker 138 | .pyre/ 139 | 140 | # pytype static type analyzer 141 | .pytype/ 142 | 143 | # Cython debug symbols 144 | cython_debug/ -------------------------------------------------------------------------------- /tests/test_compat_v1.py: -------------------------------------------------------------------------------- 1 | def test_v1_compat() -> None: 2 | from aqt import mw 3 | from addon.compat.v1 import v1_compat 4 | 5 | old_config = { 6 | "q_press_left_press_right_press_middle": "off", # press 7 | "q_wheel_down": "", # empty 8 | "q_click_right": "good", # normal 9 | "a_click_right": "undo", # normal 10 | "a_click_xbutton3": "again", 11 | "a_click_left": "strange_looking_action", 12 | "a_strange_looking_hotkey": "good", 13 | "threshold_wheel_ms": 200, 14 | "tooltip": False, 15 | "z_debug": False, 16 | } 17 | mw.addonManager.writeConfig(__name__, old_config) 18 | v1_compat() 19 | config = mw.addonManager.getConfig(__name__) 20 | assert config == { 21 | "shortcuts": { 22 | "q_press_left_press_right_click_middle": "off", 23 | "q_wheel_down": "", 24 | "q_click_right": "good", 25 | "a_click_right": "undo", 26 | }, 27 | "threshold_wheel_ms": 200, 28 | "tooltip": False, 29 | "z_debug": False, 30 | } 31 | 32 | # Test that shortcuts don't get deleted 33 | v1_compat() 34 | assert config == { 35 | "shortcuts": { 36 | "q_press_left_press_right_click_middle": "off", 37 | "q_wheel_down": "", 38 | "q_click_right": "good", 39 | "a_click_right": "undo", 40 | }, 41 | "threshold_wheel_ms": 200, 42 | "tooltip": False, 43 | "z_debug": False, 44 | } 45 | 46 | old_config = {"q_press_left": "on", "q_click_left": "off"} 47 | mw.addonManager.writeConfig(__name__, old_config) 48 | v1_compat() 49 | config = mw.addonManager.getConfig(__name__) 50 | assert config == {"shortcuts": {"q_click_left": "off"}} 51 | 52 | 53 | def test_modify_empty_action_shortcuts() -> None: 54 | from addon.compat.v1 import modify_empty_action_shortcuts 55 | 56 | shortcuts = { 57 | "q_wheel_down": "", 58 | "a_click_left": "", 59 | "a_click_middle": "easy", 60 | "a_wheel_up": "", 61 | } 62 | modify_empty_action_shortcuts(shortcuts) 63 | 64 | assert shortcuts == { 65 | "q_wheel_down": "", 66 | "a_click_left": "", 67 | "a_click_middle": "easy", 68 | "a_wheel_up": "", 69 | } 70 | 71 | 72 | def test_modify_hotkeys_ending_with_press() -> None: 73 | from addon.compat.v1 import modify_hotkeys_ending_with_press 74 | 75 | shortcuts = { 76 | "q_wheel_down_press_left": "easy", 77 | "a_press_left_press_middle_press_right": "hard", 78 | "a_press_right_click_right": "", 79 | } 80 | modify_hotkeys_ending_with_press(shortcuts) 81 | 82 | assert shortcuts == { 83 | "q_wheel_down_click_left": "easy", 84 | "a_press_left_press_middle_click_right": "hard", 85 | "a_press_right_click_right": "", 86 | } 87 | -------------------------------------------------------------------------------- /addon/compat/v1.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Tuple 2 | 3 | from aqt import mw 4 | from aqt.utils import showText 5 | 6 | from ..event import Button, ACTION_OPTS 7 | 8 | 9 | def v1_compat() -> None: 10 | """Compat code from v1. 11 | 12 | Change hotkeys ending in 'press' to 'click' 13 | Change action "" to 14 | Remove shortcuts using xbutton 2-9 15 | Remove shortcuts with invalid hotkey or action strings 16 | Inform user if their hotkeys are modified. 17 | """ 18 | shortcuts = get_and_remove_v1_shortcuts_from_config() 19 | modify_empty_action_shortcuts(shortcuts) 20 | (modified, removed) = modify_hotkeys_ending_with_press(shortcuts) 21 | removed2 = remove_invalid_shortcuts(shortcuts) 22 | removed.update(removed2) 23 | config = mw.addonManager.getConfig(__name__) 24 | config["shortcuts"] = shortcuts 25 | mw.addonManager.writeConfig(__name__, config) 26 | if modified or removed: 27 | inform_v1_shortcuts_modified(modified, removed) 28 | 29 | 30 | def get_and_remove_v1_shortcuts_from_config() -> Dict[str, str]: 31 | """Remove and returns shortcut config entries. Including config["shortcuts"]""" 32 | config = mw.addonManager.getConfig(__name__) 33 | shortcuts = {} 34 | config_keys = [ 35 | "threshold_wheel_ms", 36 | "threshold_angle", 37 | "tooltip", 38 | "z_debug", 39 | "version", 40 | "shortcuts", 41 | ] 42 | 43 | for key in config: 44 | if key in config_keys: 45 | continue 46 | shortcuts[key] = config[key] 47 | for key in shortcuts: 48 | config.pop(key) 49 | if "shortcuts" in config: 50 | existing_shortcuts = config["shortcuts"] 51 | for hotkey in existing_shortcuts: 52 | shortcuts[hotkey] = existing_shortcuts[hotkey] 53 | mw.addonManager.writeConfig(__name__, config) 54 | return shortcuts 55 | 56 | 57 | def modify_empty_action_shortcuts(shortcuts: Dict[str, str]) -> None: 58 | """Changes "" action to "".""" 59 | for hotkey in shortcuts: 60 | if shortcuts[hotkey] == "": 61 | shortcuts[hotkey] = "" 62 | 63 | 64 | def modify_hotkeys_ending_with_press( 65 | shortcuts: Dict[str, str], 66 | ) -> Tuple[Dict[str, str], Dict[str, str]]: 67 | """Modifies hotkeys ending with "press" to "click". Returns modified. 68 | 69 | If another shortcut ending with "click" exists, it is skipped. 70 | """ 71 | modified = {} 72 | removed = {} 73 | 74 | to_modify = {} 75 | for hotkey in shortcuts: 76 | hotkeylist = hotkey.split("_") 77 | if len(hotkeylist) < 2: 78 | continue 79 | new_hotkey = hotkey 80 | if hotkeylist[-2] == "press": 81 | hotkeylist[-2] = "click" 82 | new_hotkey = "_".join(hotkeylist) 83 | to_modify[hotkey] = new_hotkey 84 | 85 | for hotkey in to_modify: 86 | new_hotkey = to_modify[hotkey] 87 | if new_hotkey in shortcuts: 88 | removed[hotkey] = shortcuts.pop(hotkey) 89 | else: 90 | shortcuts[new_hotkey] = shortcuts.pop(hotkey) 91 | modified[hotkey] = new_hotkey 92 | 93 | return (modified, removed) 94 | 95 | 96 | def is_valid_hotkey(hotkey: str) -> bool: 97 | """Returns True if hotkey string is valid.""" 98 | mode_opts = ["press", "click", "wheel"] 99 | btn_opts = [b.name for b in Button] 100 | wheel_opts = ["up", "down"] 101 | 102 | hotkeylist = hotkey[2:].split("_") 103 | if len(hotkeylist) == 0 or len(hotkeylist) % 2 != 0: 104 | return False 105 | for i in range(0, len(hotkeylist), 2): 106 | mode = hotkeylist[i] 107 | btn = hotkeylist[i + 1] 108 | if mode not in mode_opts: 109 | return False 110 | if mode == "wheel" and btn not in wheel_opts: 111 | return False 112 | elif mode in ("press", "click") and btn not in btn_opts: 113 | return False 114 | if hotkey[-2] == "press": 115 | return False 116 | return True 117 | 118 | 119 | def is_valid_action(action: str) -> bool: 120 | """Returns True if action string is valid.""" 121 | if action not in ACTION_OPTS: 122 | return False 123 | return True 124 | 125 | 126 | def remove_invalid_shortcuts(shortcuts: Dict[str, str]) -> Dict[str, str]: 127 | """Removes shortcuts that has invalid hotkey or action strings. Returns removed.""" 128 | remove = {} 129 | for hotkey in shortcuts: 130 | action = shortcuts[hotkey] 131 | if is_valid_hotkey(hotkey) and is_valid_action(action): 132 | continue 133 | remove[hotkey] = "" 134 | for hotkey in remove: 135 | remove[hotkey] = shortcuts.pop(hotkey) 136 | return remove 137 | 138 | 139 | def inform_v1_shortcuts_modified(mod: Dict[str, str], rem: Dict[str, str]) -> None: 140 | """Notifies user about how shortcuts were changed. 141 | 142 | - mod: {original_hotkey: new_hotkey} 143 | - rem: {hotkey: action} 144 | """ 145 | base = ( 146 | "

Review Hotmouse Update Notes

" 147 | "Some shortcuts were invalid, and were modified or removed for compatibility. " 148 | "This may be because the shortcut wasn't valid in the first case, " 149 | "or it is no longer valid after updating." 150 | "

" 151 | "For example, 'press' only hotkeys are no longer valid " 152 | "and hotkeys must now contain either 'click' or 'wheel'." 153 | "

" 154 | "If you are not sure why a shortcut was modified or deleted, please create an issue at the " 155 | 'Github repo.' 156 | "
" 157 | "{mod}" 158 | "
" 159 | "{rem}" 160 | ) 161 | mod_msg = "" 162 | rem_msg = "" 163 | if mod: 164 | mod_msg = "
List of modified hotkeys:" 165 | for hotkey in mod: 166 | mod_msg += "
" 167 | mod_msg += f"{hotkey} → {mod[hotkey]}" 168 | if rem: 169 | rem_msg = "
List of deleted hotkeys:" 170 | for hotkey in rem: 171 | rem_msg += "
" 172 | rem_msg += f"{hotkey}: {rem[hotkey]}" 173 | msg = base.format(mod=mod_msg, rem=rem_msg) 174 | showText(msg, mw, type="html", title="Review Hotmouse", copyBtn=True) 175 | -------------------------------------------------------------------------------- /addon/config.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple, Optional, List, Dict, Tuple, Union, Literal 2 | 3 | from aqt.qt import * 4 | 5 | from .ankiaddonconfig import * 6 | from .event import ACTION_OPTS, Button, refresh_config 7 | 8 | 9 | def general_tab(conf_window: ConfigWindow) -> None: 10 | tab = conf_window.add_tab("General") 11 | tab.number_input( 12 | "threshold_wheel_ms", 13 | "Mouse scroll threshold (1000 is 1s)", 14 | tooltip="How long a delay between subsequent scroll actions?", 15 | maximum=3000, 16 | ) 17 | tab.checkbox( 18 | "default_enabled", 19 | "add-on is enabled at start", 20 | "If you uncheck this box, the add-on will start as turned off when Anki is launched", 21 | ) 22 | tab.checkbox("tooltip", "When triggered, show action name") 23 | tab.checkbox("z_debug", "Debugging: Show hotkey on mouse action") 24 | tab.stretch() 25 | 26 | 27 | class Options(NamedTuple): 28 | mode: List[str] 29 | button: List[str] 30 | wheel: List[str] 31 | action: List[str] 32 | 33 | 34 | OPTS = Options( 35 | mode=["press", "click", "wheel"], 36 | button=[b.name for b in Button], 37 | wheel=["up", "down"], 38 | action=ACTION_OPTS, 39 | ) 40 | 41 | 42 | class DDConfigLayout(ConfigLayout): 43 | def __init__(self, conf_window: ConfigWindow): 44 | super().__init__(conf_window, QBoxLayout.Direction.LeftToRight) 45 | self.dropdowns: List[QComboBox] = [] 46 | 47 | def create_dropdown( 48 | self, current: str, options: List[str], is_mode: bool = False 49 | ) -> QComboBox: 50 | dropdown = QComboBox() 51 | dropdown.insertItems(0, options) 52 | dropdown.setCurrentIndex(options.index(current)) 53 | self.addWidget(dropdown) 54 | self.dropdowns.append(dropdown) 55 | if is_mode: 56 | ddidx = len(self.dropdowns) - 1 57 | dropdown.currentIndexChanged.connect( 58 | lambda optidx, d=ddidx: self.on_mode_change(optidx, d) 59 | ) 60 | return dropdown 61 | 62 | def on_mode_change(self, optidx: int, ddidx: int) -> None: 63 | """Handler for when mode dropdown changes""" 64 | mode = OPTS.mode[optidx] 65 | dropdowns = self.dropdowns 66 | if mode == "press": 67 | dd = dropdowns.pop(ddidx + 1) 68 | self.removeWidget(dd) 69 | dd.deleteLater() 70 | self.create_dropdown(OPTS.button[0], OPTS.button) 71 | self.create_dropdown("click", OPTS.mode, is_mode=True) 72 | self.create_dropdown(OPTS.button[0], OPTS.button) 73 | else: 74 | while len(dropdowns) > ddidx + 1: 75 | # make this the last dropdown 76 | dd = dropdowns.pop() 77 | self.removeWidget(dd) 78 | dd.deleteLater() 79 | if mode == "click": 80 | self.create_dropdown(OPTS.button[0], OPTS.button) 81 | else: # mode == "wheel" 82 | self.create_dropdown(OPTS.wheel[0], OPTS.wheel) 83 | 84 | 85 | class HotkeyTabManager: 86 | def __init__( 87 | self, tab: ConfigLayout, side: Union[Literal["q"], Literal["a"]] 88 | ) -> None: 89 | self.tab = tab 90 | self.config_window = tab.config_window 91 | self.side = side 92 | # For each row, (hotkey_layout, action_layout) 93 | self.layouts: List[Tuple[DDConfigLayout, DDConfigLayout]] = [] 94 | self.setup_tab() 95 | 96 | def setup_tab(self) -> None: 97 | tab = self.tab 98 | self.rows_layout = self.tab.vlayout() 99 | btn_layout = tab.hlayout() 100 | add_btn = QPushButton("+ Add New ") 101 | add_btn.clicked.connect( 102 | lambda _: self.add_row(f"{self.side}_click_right", "") 103 | ) 104 | add_btn.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) 105 | add_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) 106 | btn_layout.addWidget(add_btn) 107 | btn_layout.stretch() 108 | btn_layout.setContentsMargins(0, 20, 0, 5) 109 | tab.setSpacing(0) 110 | tab.addLayout(btn_layout) 111 | tab.stretch() 112 | tab.space(10) 113 | tab.text("If you set duplicate hotkeys, only the last one will be saved.") 114 | 115 | def create_layout(self) -> DDConfigLayout: 116 | return DDConfigLayout(self.config_window) 117 | 118 | def clear_rows(self) -> None: 119 | """Clear all rows in tab. Run before setup_rows.""" 120 | for i in range(self.rows_layout.count()): 121 | widget = self.rows_layout.itemAt(0).widget() 122 | self.rows_layout.removeWidget(widget) 123 | widget.deleteLater() 124 | self.layouts = [] 125 | 126 | def setup_rows(self) -> None: 127 | hotkeys = conf.get("shortcuts") 128 | for hotkey in hotkeys: 129 | if hotkey[0] == self.side: 130 | self.add_row(hotkey, hotkeys[hotkey]) 131 | 132 | def hotkey_layout(self, hotkey: str) -> Optional[DDConfigLayout]: 133 | """hotkey eg: `q_press_left_click_right`. Returns None if hotkey is invalid.""" 134 | layout = self.create_layout() 135 | hotkeylist = hotkey[2:].split("_") 136 | if len(hotkeylist) % 2 != 0: 137 | return None 138 | for i in range(0, len(hotkeylist), 2): 139 | mode = hotkeylist[i] 140 | btn = hotkeylist[i + 1] 141 | if mode not in OPTS.mode: 142 | return None 143 | if mode == "wheel" and btn not in OPTS.wheel: 144 | return None 145 | if mode in ("press", "click") and btn not in OPTS.button: 146 | return None 147 | 148 | layout.create_dropdown(mode, OPTS.mode, is_mode=True) 149 | if mode == "wheel": 150 | layout.create_dropdown(btn, OPTS.wheel) 151 | else: 152 | layout.create_dropdown(btn, OPTS.button) 153 | return layout 154 | 155 | def action_layout(self, action: str) -> Optional[DDConfigLayout]: 156 | """Returns None if action string is invalid.""" 157 | if action not in OPTS.action: 158 | return None 159 | layout = self.create_layout() 160 | layout.stretch() 161 | layout.text("  ", html=True) 162 | act_opts = OPTS.action 163 | layout.create_dropdown(action, act_opts) 164 | return layout 165 | 166 | def add_row(self, hotkey: str, action: str) -> None: 167 | hlay = self.hotkey_layout(hotkey) 168 | alay = self.action_layout(action) 169 | if hlay and alay: 170 | container = QWidget() 171 | layout = self.create_layout() 172 | layout.setContentsMargins(0, 4, 2, 4) # decrease margin 173 | container.setLayout(layout) 174 | self.rows_layout.addWidget(container) 175 | layout.addLayout(hlay) 176 | layout.addLayout(alay) 177 | layout_tuple = (hlay, alay) 178 | self.layouts.append(layout_tuple) 179 | label = QLabel( 180 | " " 181 | ) 182 | label.setTextFormat(Qt.TextFormat.RichText) 183 | label.setToolTip("Delete this shortcut.") 184 | layout.addWidget(label) 185 | 186 | def remove(l: str, layouts: Tuple[DDConfigLayout, DDConfigLayout]) -> None: 187 | self.rows_layout.removeWidget(container) 188 | container.deleteLater() 189 | self.layouts.remove(layouts) 190 | 191 | label.linkActivated.connect(lambda l, t=layout_tuple: remove(l, t)) 192 | 193 | def on_update(self) -> None: 194 | self.clear_rows() 195 | self.setup_rows() 196 | 197 | def get_data(self, hotkeys_data: Dict[str, str]) -> None: 198 | """Adds hotkey entries to hotkeys_data dictionary.""" 199 | for row in self.layouts: 200 | hotkey_layout = row[0] 201 | action_layout = row[1] 202 | hotkey_str = self.side # type: str 203 | for dd in hotkey_layout.dropdowns: 204 | hotkey_str += "_" + dd.currentText() 205 | hotkey_str = self.sort_hotkey_btn(hotkey_str) 206 | action_str = action_layout.dropdowns[0].currentText() 207 | hotkeys_data[hotkey_str] = action_str 208 | 209 | @staticmethod 210 | def sort_hotkey_btn(hotkey_str: str) -> str: 211 | """Sort button order for 'press' in hotkey_str.""" 212 | hotkeylist = hotkey_str.split("_") 213 | if len(hotkeylist) - 1 <= 4: 214 | # Doesn't have multiple 'press' 215 | return hotkey_str 216 | btns = [] 217 | btn_names = [b.name for b in Button] 218 | for i in range(1, len(hotkeylist) - 2, 2): 219 | btn = hotkeylist[i + 1] 220 | btns.append(btn) 221 | btns = sorted(btns, key=lambda x: btn_names.index(x)) 222 | new_hotkey_str = "{}_".format(hotkeylist[0]) 223 | for btn in btns: 224 | new_hotkey_str += "press_" 225 | new_hotkey_str += "{}_".format(btn) 226 | new_hotkey_str += "{}_{}".format(hotkeylist[-2], hotkeylist[-1]) 227 | return new_hotkey_str 228 | 229 | 230 | def hotkey_tabs(conf_window: ConfigWindow) -> None: 231 | q_tab = conf_window.add_tab("Question Hotkeys") 232 | q_manager = HotkeyTabManager(q_tab, "q") 233 | a_tab = conf_window.add_tab("Answer Hotkeys") 234 | a_manager = HotkeyTabManager(a_tab, "a") 235 | conf_window.widget_updates.append(q_manager.on_update) 236 | conf_window.widget_updates.append(a_manager.on_update) 237 | 238 | def save_hotkeys() -> None: 239 | hotkeys: Dict[str, str] = {} 240 | q_manager.get_data(hotkeys) 241 | a_manager.get_data(hotkeys) 242 | conf_window.conf.set("shortcuts", hotkeys) 243 | 244 | conf_window.execute_on_save(save_hotkeys) 245 | 246 | 247 | def on_window_open(conf_window: ConfigWindow) -> None: 248 | conf_window.execute_on_close(refresh_config) 249 | 250 | 251 | conf = ConfigManager() 252 | conf.use_custom_window() 253 | conf.on_window_open(on_window_open) 254 | conf.add_config_tab(general_tab) 255 | conf.add_config_tab(hotkey_tabs) 256 | -------------------------------------------------------------------------------- /addon/event.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, List, Dict, Optional, Union, Tuple, no_type_check 2 | from enum import Enum 3 | import datetime 4 | import json 5 | 6 | from anki.hooks import wrap 7 | from aqt import mw, gui_hooks 8 | from aqt.qt import * 9 | from aqt.utils import tooltip 10 | from aqt.webview import AnkiWebView, WebContent 11 | import aqt 12 | 13 | 14 | def WEBVIEW_TARGETS() -> List[AnkiWebView]: 15 | # Implemented as a function so attributes are resolved when called. 16 | # In case mw.web is reassigned to a different object 17 | return [mw.web, mw.bottomWeb] 18 | 19 | 20 | config = mw.addonManager.getConfig(__name__) 21 | 22 | 23 | def refresh_config() -> None: 24 | global config 25 | config = mw.addonManager.getConfig(__name__) 26 | manager.refresh_shortcuts() 27 | 28 | 29 | def turn_on() -> None: 30 | if not manager.enabled: 31 | manager.enable() 32 | tooltip("Enabled hotmouse") 33 | 34 | 35 | def turn_off() -> None: 36 | if manager.enabled: 37 | manager.disable() 38 | tooltip("Disabled hotmouse") 39 | 40 | 41 | def toggle_on_off() -> None: 42 | if manager.enabled: 43 | manager.disable() 44 | tooltip("Disabled hotmouse") 45 | else: 46 | manager.enable() 47 | tooltip("Enabled hotmouse") 48 | 49 | 50 | def answer_again() -> None: 51 | if mw.reviewer.state == "question": 52 | mw.reviewer.state = "answer" 53 | mw.reviewer._answerCard(1) 54 | 55 | 56 | def answer_hard() -> None: 57 | if mw.reviewer.state == "question": 58 | mw.reviewer.state = "answer" 59 | cnt = mw.col.sched.answerButtons(mw.reviewer.card) 60 | if cnt == 4: 61 | mw.reviewer._answerCard(2) 62 | 63 | 64 | def answer_good() -> None: 65 | if mw.reviewer.state == "question": 66 | mw.reviewer.state = "answer" 67 | cnt = mw.col.sched.answerButtons(mw.reviewer.card) 68 | if cnt == 2: 69 | mw.reviewer._answerCard(2) 70 | elif cnt == 3: 71 | mw.reviewer._answerCard(2) 72 | elif cnt == 4: 73 | mw.reviewer._answerCard(3) 74 | 75 | 76 | def answer_easy() -> None: 77 | if mw.reviewer.state == "question": 78 | mw.reviewer.state = "answer" 79 | cnt = mw.col.sched.answerButtons(mw.reviewer.card) 80 | if cnt == 3: 81 | mw.reviewer._answerCard(3) 82 | elif cnt == 4: 83 | mw.reviewer._answerCard(4) 84 | 85 | 86 | def toggle_auto_advance() -> None: 87 | # This method was added v23.12 88 | if hasattr(mw.reviewer, "toggle_auto_advance"): 89 | mw.reviewer.toggle_auto_advance() 90 | else: 91 | tooltip( 92 | "Review Hotmouse: Your Anki version does not support 'toggle_auto_advance'" 93 | ) 94 | 95 | 96 | ACTIONS = { 97 | "": lambda: None, 98 | "on": turn_on, 99 | "off": turn_off, 100 | "on_off": toggle_on_off, 101 | "undo": lambda: mw.onUndo() if mw.form.actionUndo.isEnabled() else None, 102 | "show_ans": lambda: mw.reviewer._getTypedAnswer(), 103 | "again": answer_again, 104 | "hard": answer_hard, 105 | "good": answer_good, 106 | "easy": answer_easy, 107 | "delete": lambda: mw.reviewer.onDelete(), 108 | "suspend_card": lambda: mw.reviewer.onSuspendCard(), 109 | "suspend_note": lambda: mw.reviewer.onSuspend(), 110 | "bury_card": lambda: mw.reviewer.onBuryCard(), 111 | "bury_note": lambda: mw.reviewer.onBuryNote(), 112 | "mark": lambda: mw.reviewer.onMark(), 113 | "red": lambda: mw.reviewer.setFlag(1), 114 | "orange": lambda: mw.reviewer.setFlag(2), 115 | "green": lambda: mw.reviewer.setFlag(3), 116 | "blue": lambda: mw.reviewer.setFlag(4), 117 | "audio": lambda: mw.reviewer.replayAudio(), 118 | "record_voice": lambda: mw.reviewer.onRecordVoice(), 119 | "replay_voice": lambda: mw.reviewer.onReplayRecorded(), 120 | "replay_audio": lambda: mw.reviewer.replayAudio(), 121 | "toggle_auto_advance": toggle_auto_advance, 122 | "copy": lambda: mw.reviewer.web.triggerPageAction(QWebEnginePage.WebAction.Copy), 123 | "paste": lambda: mw.reviewer.web.triggerPageAction(QWebEnginePage.WebAction.Paste), 124 | } 125 | ACTION_OPTS = list(ACTIONS.keys()) 126 | 127 | 128 | class Button(Enum): 129 | left = Qt.MouseButton.LeftButton 130 | right = Qt.MouseButton.RightButton 131 | middle = Qt.MouseButton.MiddleButton 132 | xbutton1 = Qt.MouseButton.XButton1 133 | xbutton2 = Qt.MouseButton.XButton2 134 | 135 | @classmethod 136 | def from_qt(cls, btn: Qt.MouseButton) -> Optional["Button"]: 137 | try: 138 | return Button(btn) 139 | except ValueError: 140 | print(f"Review Hotmouse: Unknown Button Pressed: {btn}") 141 | return None 142 | 143 | 144 | class WheelDir(Enum): 145 | DOWN = -1 146 | UP = 1 147 | 148 | @classmethod 149 | def from_qt(cls, angle_delta: QPoint) -> Optional["WheelDir"]: 150 | delta = angle_delta.y() 151 | if delta > 0: 152 | return cls.UP 153 | elif delta < 0: 154 | return cls.DOWN 155 | else: 156 | return None 157 | 158 | @classmethod 159 | def from_web(cls, delta: int) -> Optional["WheelDir"]: 160 | """web and qt has opposite delta sign""" 161 | if delta < 0: 162 | return cls.UP 163 | elif delta > 0: 164 | return cls.DOWN 165 | else: 166 | return None 167 | 168 | 169 | class HotmouseManager: 170 | has_wheel_hotkey: bool 171 | 172 | def __init__(self) -> None: 173 | self.enabled = config["default_enabled"] 174 | self.last_scroll_time = datetime.datetime.now() 175 | self.add_menu() 176 | self.refresh_shortcuts() 177 | 178 | def add_menu(self) -> None: 179 | self.action = QAction("Enable/Disable Review Hotmouse", mw) 180 | self.action.triggered.connect(toggle_on_off) 181 | mw.form.menuTools.addAction(self.action) 182 | self.update_menu() 183 | 184 | def update_menu(self) -> None: 185 | if self.enabled: 186 | label = "Disable Review Hotmouse" 187 | else: 188 | label = "Enable Review Hotmouse" 189 | self.action.setText(label) 190 | 191 | def enable(self) -> None: 192 | self.enabled = True 193 | self.update_menu() 194 | 195 | def disable(self) -> None: 196 | self.enabled = False 197 | self.update_menu() 198 | 199 | def refresh_shortcuts(self) -> None: 200 | self.has_wheel_hotkey = False 201 | for shortcut in config["shortcuts"]: 202 | if "wheel" in shortcut: 203 | self.has_wheel_hotkey = True 204 | break 205 | print("has wheel", self.has_wheel_hotkey) 206 | 207 | def uses_btn(self, btn: Button) -> bool: 208 | for shortcut in config["shortcuts"]: 209 | if btn.name in shortcut: 210 | return True 211 | return False 212 | 213 | @staticmethod 214 | def get_pressed_buttons(qbuttons: "Qt.MouseButton") -> List[Button]: 215 | """Returns list of pressed button names, excluding the button that caused the trigger""" 216 | buttons = [] 217 | for b in Button: 218 | if qbuttons & b.value: # type: ignore 219 | buttons.append(b) 220 | return buttons 221 | 222 | @staticmethod 223 | def build_hotkey( 224 | btns: List[Button], 225 | wheel: Optional[WheelDir] = None, 226 | click: Optional[Button] = None, 227 | ) -> str: 228 | """One and only one of wheel or click must be passed as an argument.""" 229 | if mw.reviewer.state == "question": 230 | shortcut_key_str = "q" 231 | elif mw.reviewer.state == "answer": 232 | shortcut_key_str = "a" 233 | else: 234 | shortcut_key_str = "x" # ignore transition 235 | 236 | for btn in btns: 237 | shortcut_key_str += "_press_{}".format(btn.name) 238 | if click: 239 | shortcut_key_str += "_click_{}".format(click.name) 240 | if wheel == WheelDir.UP: 241 | shortcut_key_str += "_wheel_up" 242 | elif wheel == WheelDir.DOWN: 243 | shortcut_key_str += "_wheel_down" 244 | return shortcut_key_str 245 | 246 | def execute_shortcut(self, hotkey_str: str) -> bool: 247 | """Returns True if shortcut exists and is executed.""" 248 | if self.enabled and config["z_debug"]: 249 | tooltip(hotkey_str) 250 | shortcuts = config["shortcuts"] 251 | if hotkey_str in shortcuts: 252 | action_str = shortcuts[hotkey_str] 253 | else: 254 | action_str = "" 255 | 256 | if not self.enabled and action_str not in ("on", "on_off"): 257 | return False 258 | if not action_str: 259 | return False 260 | if config["tooltip"]: 261 | tooltip(action_str) 262 | ACTIONS[action_str]() 263 | return True 264 | 265 | def on_mouse_press(self, event: QMouseEvent) -> bool: 266 | """Returns True if shortcut is executed""" 267 | btns = self.get_pressed_buttons(event.buttons()) 268 | pressed = Button.from_qt(event.button()) 269 | if pressed is None: 270 | return False 271 | btns.remove(pressed) 272 | hotkey_str = self.build_hotkey(btns, click=pressed) 273 | return self.execute_shortcut(hotkey_str) 274 | 275 | def on_mouse_scroll(self, event: QWheelEvent) -> bool: 276 | """Returns True if shortcut is executed""" 277 | wheel_dir = WheelDir.from_qt(event.angleDelta()) 278 | if wheel_dir is None: 279 | return False 280 | return self.handle_scroll(wheel_dir, event.buttons()) 281 | 282 | def handle_scroll(self, wheel_dir: WheelDir, qbtns: "Qt.MouseButton") -> bool: 283 | """Returns True if shortcut is executed""" 284 | curr_time = datetime.datetime.now() 285 | time_diff = curr_time - self.last_scroll_time 286 | self.last_scroll_time = curr_time 287 | if time_diff.total_seconds() * 1000 > config["threshold_wheel_ms"]: 288 | btns = self.get_pressed_buttons(qbtns) 289 | hotkey_str = self.build_hotkey(btns, wheel=wheel_dir) 290 | return self.execute_shortcut(hotkey_str) 291 | else: 292 | return self.enabled 293 | 294 | 295 | class HotmouseEventFilter(QObject): 296 | @no_type_check 297 | def eventFilter(self, obj: QObject, event: QEvent) -> bool: 298 | """Because Mouse events are triggered on QWebEngineView's child widgets. 299 | 300 | Event propagation is only stopped when shortcut is triggered. 301 | This is so clicking on answer buttons and selecting text works. 302 | And `left_click` shortcut should be discouraged because of above. 303 | """ 304 | if mw.state == "review": 305 | if event.type() == QEvent.Type.MouseButtonPress: 306 | if manager.on_mouse_press(event): 307 | return True 308 | elif event.type() == QEvent.Type.MouseButtonRelease: 309 | if manager.enabled: 310 | btn = Button.from_qt(event.button()) 311 | if btn is None: 312 | return False 313 | # Prevent back/forward navigation 314 | if btn == Button.xbutton1 or btn == Button.xbutton2: 315 | if manager.uses_btn(btn): 316 | return True 317 | elif event.type() == QEvent.Type.Wheel: 318 | if manager.has_wheel_hotkey and manager.on_mouse_scroll(event): 319 | return True 320 | elif event.type() == QEvent.Type.ContextMenu: 321 | if manager.enabled and manager.uses_btn(Button.right): 322 | return True # ignore event 323 | if event.type() == QEvent.Type.ChildAdded: 324 | add_event_filter(event.child()) 325 | return False 326 | 327 | 328 | def add_event_filter(object: QObject) -> None: 329 | """Add event filter to the widget and its children, to master""" 330 | # Event filters are activated in the order they are installed. 331 | object.installEventFilter(hotmouseEventFilter) 332 | child_object = object.children() 333 | for w in child_object: 334 | add_event_filter(w) 335 | 336 | 337 | @no_type_check 338 | def install_event_handlers() -> None: 339 | for target in WEBVIEW_TARGETS(): 340 | add_event_filter(target) 341 | # Not sure why, but context menu events are not 100% filtered through event filters 342 | if hasattr(AnkiWebView, "contextMenuEvent"): 343 | AnkiWebView.contextMenuEvent = wrap( 344 | AnkiWebView.contextMenuEvent, on_context_menu, "around" 345 | ) 346 | else: 347 | AnkiWebView.contextMenuEvent = on_context_menu 348 | 349 | 350 | def on_context_menu( 351 | target: QWebEngineView, 352 | ev: QContextMenuEvent, 353 | _old: Callable = lambda t, e: None, 354 | ) -> None: 355 | if target not in WEBVIEW_TARGETS(): 356 | _old(target, ev) 357 | return 358 | if manager.enabled and mw.state == "review" and manager.uses_btn(Button.right): 359 | return None # ignore event 360 | _old(target, ev) 361 | 362 | 363 | def add_context_menu_action(wv: AnkiWebView, m: QMenu) -> None: 364 | if mw.state == "review" and not manager.enabled: 365 | a = m.addAction("Enable Hotmouse") 366 | a.triggered.connect(turn_on) 367 | 368 | 369 | def inject_web_content(web_content: WebContent, context: Optional[Any]) -> None: 370 | """Wheel events are not reliably detected with qt's event handler 371 | when the reviewer is scrollable. (For long cards) 372 | """ 373 | if not isinstance(context, aqt.reviewer.Reviewer): 374 | return 375 | addon_package = mw.addonManager.addonFromModule(__name__) 376 | web_content.js.append(f"/_addons/{addon_package}/web/detect_wheel.js") 377 | 378 | 379 | def handle_js_message( 380 | handled: Tuple[bool, Any], message: str, context: Any 381 | ) -> Tuple[bool, Any]: 382 | """Receive pycmd message. Returns (handled?, return_value)""" 383 | if not isinstance(context, aqt.reviewer.Reviewer): 384 | return handled 385 | addon_key = "ReviewHotmouse#" 386 | if not message.startswith(addon_key): 387 | return handled 388 | 389 | req = json.loads(message[len(addon_key) :]) # type: Dict[str, Any] 390 | if req["key"] == "wheel": 391 | wheel_delta = req["value"] # type: int 392 | wheel_dir = WheelDir.from_web(wheel_delta) 393 | if wheel_dir is None: 394 | return (False, None) 395 | qbtns = mw.app.mouseButtons() 396 | executed = manager.handle_scroll(wheel_dir, qbtns) 397 | return (executed, executed) 398 | 399 | return handled 400 | 401 | 402 | manager = HotmouseManager() 403 | hotmouseEventFilter = HotmouseEventFilter() 404 | 405 | mw.addonManager.setWebExports(__name__, r"web/.*(css|js)") 406 | gui_hooks.main_window_did_init.append(install_event_handlers) # 2.1.28 407 | gui_hooks.webview_will_show_context_menu.append(add_context_menu_action) # 2.1.20 408 | gui_hooks.webview_will_set_content.append(inject_web_content) # 2.1.22 409 | gui_hooks.webview_did_receive_js_message.append(handle_js_message) # 2.1.20 410 | --------------------------------------------------------------------------------