├── .gitattributes ├── .gitignore ├── LICENSE ├── Readme.md ├── gestureflow.py ├── public └── 2024-07-1418-29-11-ezgif.com-video-to-gif-converter.gif └── requirements.py /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/gestureflow.exe filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | share/python-wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | *.py,cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | cover/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | docsrc/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyderworkspace 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # pytype static type analyzer 132 | .pytype/ 133 | 134 | # Cython debug symbols 135 | cython_debug/ 136 | 137 | # End Generation Here 138 | dist/ 139 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 tylerbry 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 | # GestureFlow 2 | 3 | GestureFlow: A application that enhances right-click functionality with a customizable radial menu for quick access to common actions, activated by holding the right mouse button. 4 | 5 | ![GestureFlow Demo](public/2024-07-1418-29-11-ezgif.com-video-to-gif-converter.gif) 6 | 7 | ## Running the Project 8 | 9 | You can run the project in two ways: 10 | 11 | 1. **Run from Source:** 12 | - Install all the requirements (preferably in a virtual environment): 13 | ```sh 14 | pip install -r requirements.txt 15 | ``` 16 | - Run the `gestureflow.py` file: 17 | ```sh 18 | python gestureflow.py 19 | ``` 20 | 21 | 2. **Run the Executable:** 22 | - Download the pre-built executable from the `dist` folder. 23 | - Double-click the executable to run it. 24 | 25 | ## Building the Executable 26 | 27 | To build the executable yourself: 28 | 29 | 1. Install PyInstaller: 30 | ```sh 31 | pip install pyinstaller 32 | ``` 33 | 34 | 2. Run the following command in the project directory: 35 | ```sh 36 | python -m PyInstaller --onefile --windowed "gestureflow.py" 37 | ``` 38 | 39 | 3. Find the generated executable in the `dist` folder. 40 | 41 | **Note:** If you encounter any issues with PyQt6, you can exclude it from the build. 42 | 43 | 44 | ## Contributing 45 | 46 | We welcome contributions from the community! If you have any ideas, suggestions, or bug reports, please feel free to open an issue or submit a pull request. Your contributions are greatly appreciated! -------------------------------------------------------------------------------- /gestureflow.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import math 3 | import platform 4 | import time 5 | from PyQt5.QtWidgets import QApplication, QDialog, QGraphicsView, QGraphicsScene, QGraphicsItem 6 | from PyQt5.QtCore import Qt, QPointF, QRectF, QTimer, pyqtSignal, QObject 7 | from PyQt5.QtGui import QPen, QColor, QBrush, QPainterPath, QPainter, QFont, QFontMetrics 8 | import pyautogui 9 | from pynput import mouse 10 | 11 | pyautogui.FAILSAFE = False 12 | 13 | IS_MACOS = platform.system() == "Darwin" 14 | COMMAND_KEY = "command" if IS_MACOS else "ctrl" 15 | PREVIOUS_KEY = "[" if IS_MACOS else "left" 16 | DELETE_KEY = "delete" if IS_MACOS else "del" 17 | 18 | # Configuration options for customization 19 | MENU_SIZE = 300 20 | CENTER_RADIUS = 50 21 | OUTER_RADIUS = 150 22 | SLICE_COLOR = QColor(30, 30, 30, 220) # Dark Charcoal 23 | SLICE_HOVER_COLOR = QColor(0, 100, 255, 180) # Neon Blue 24 | BORDER_COLOR = QColor(0, 255, 255, 200) # Neon Blue 25 | INNER_ELLIPSE_COLOR = QColor(0, 255, 0, 200) # Neon Green 26 | FONT_FAMILY = "Poppins" 27 | ACTION_FONT_SIZE = 6 28 | SHORTCUT_FONT_SIZE = 5 29 | DEBUG = True 30 | 31 | def debug_print(message): 32 | if DEBUG: 33 | print(message) 34 | 35 | class GlobalMouseTracker(QObject): 36 | mouse_pressed = pyqtSignal(int, int) 37 | mouse_released = pyqtSignal(int, int) 38 | mouse_moved = pyqtSignal(int, int) 39 | 40 | def __init__(self): 41 | super().__init__() 42 | self.listener = mouse.Listener(on_click=self.on_click, on_move=self.on_move) 43 | self.listener.start() 44 | 45 | def on_click(self, x, y, button, pressed): 46 | if button == mouse.Button.right: 47 | if pressed: 48 | self.mouse_pressed.emit(int(x), int(y)) 49 | else: 50 | self.mouse_released.emit(int(x), int(y)) 51 | 52 | def on_move(self, x, y): 53 | self.mouse_moved.emit(int(x), int(y)) 54 | 55 | class RadialMenuSlice(QGraphicsItem): 56 | def __init__(self, center, inner_radius, outer_radius, start_angle, end_angle, color, hover_color, parent=None): 57 | super().__init__(parent) 58 | self.center = center 59 | self.inner_radius = inner_radius 60 | self.outer_radius = outer_radius 61 | self.start_angle = start_angle 62 | self.end_angle = end_angle 63 | self.color = color 64 | self.hover_color = hover_color 65 | self.is_hovered = False 66 | self.setAcceptHoverEvents(True) 67 | 68 | def boundingRect(self): 69 | return QRectF(self.center.x() - self.outer_radius, self.center.y() - self.outer_radius, 70 | self.outer_radius * 2, self.outer_radius * 2) 71 | 72 | def paint(self, painter, option, widget): 73 | painter.setRenderHint(QPainter.Antialiasing) 74 | path = QPainterPath() 75 | start_point = self.center + QPointF( 76 | self.inner_radius * math.cos(self.start_angle), 77 | self.inner_radius * math.sin(self.start_angle) 78 | ) 79 | path.moveTo(start_point) 80 | path.arcTo(self.boundingRect(), math.degrees(-self.start_angle), math.degrees(self.start_angle - self.end_angle)) 81 | path.arcTo(QRectF(self.center.x() - self.inner_radius, self.center.y() - self.inner_radius, 82 | self.inner_radius * 2, self.inner_radius * 2), 83 | math.degrees(-self.end_angle), math.degrees(self.end_angle - self.start_angle)) 84 | path.closeSubpath() 85 | painter.setBrush(self.hover_color if self.is_hovered else self.color) 86 | painter.setPen(Qt.NoPen) 87 | painter.drawPath(path) 88 | 89 | if self.is_hovered: 90 | painter.setPen(QPen(BORDER_COLOR, 2)) 91 | painter.drawPath(path) 92 | painter.setBrush(QBrush(INNER_ELLIPSE_COLOR)) 93 | painter.drawEllipse(self.center, self.inner_radius - 5, self.inner_radius - 5) 94 | 95 | def hoverEnterEvent(self, event): 96 | self.is_hovered = True 97 | self.update() 98 | 99 | def hoverLeaveEvent(self, event): 100 | self.is_hovered = False 101 | self.update() 102 | 103 | class RadialMenu(QDialog): 104 | def __init__(self): 105 | super().__init__() 106 | self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint) 107 | self.setAttribute(Qt.WA_TranslucentBackground) 108 | self.setStyleSheet("background: transparent;") 109 | self.setFixedSize(MENU_SIZE, MENU_SIZE) 110 | 111 | self.view = QGraphicsView(self) 112 | self.view.setRenderHint(QPainter.Antialiasing) 113 | self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 114 | self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 115 | self.view.setStyleSheet("background: transparent; border: none;") 116 | self.view.setFixedSize(MENU_SIZE, MENU_SIZE) 117 | 118 | self.scene = QGraphicsScene(0, 0, MENU_SIZE, MENU_SIZE) 119 | self.view.setScene(self.scene) 120 | 121 | self.actions = [] 122 | 123 | self.slices = [] 124 | self.texts = [] 125 | self.selected = None 126 | self.center_item = None 127 | 128 | def draw_menu(self): 129 | self.scene.clear() 130 | self.slices = [] 131 | self.texts = [] 132 | 133 | center = QPointF(MENU_SIZE / 2, MENU_SIZE / 2) 134 | start_angle = -math.pi / 2 135 | 136 | for i, (action, _, shortcut) in enumerate(self.actions): 137 | end_angle = start_angle - 2 * math.pi / len(self.actions) 138 | slice_item = RadialMenuSlice( 139 | center, CENTER_RADIUS, OUTER_RADIUS, 140 | start_angle, end_angle, 141 | SLICE_COLOR, SLICE_HOVER_COLOR 142 | ) 143 | self.scene.addItem(slice_item) 144 | self.slices.append(slice_item) 145 | 146 | text_angle = (start_angle + end_angle) / 2 147 | text_radius = (CENTER_RADIUS + OUTER_RADIUS) / 2 148 | text_pos = center + QPointF(text_radius * math.cos(text_angle), text_radius * math.sin(text_angle)) 149 | 150 | text_item = self.create_text_item(f"{action}", ACTION_FONT_SIZE, Qt.white, text_pos, 50) 151 | self.texts.append(text_item) 152 | 153 | shortcut_item = self.create_text_item(f"{shortcut}", SHORTCUT_FONT_SIZE, Qt.lightGray, text_pos, 50, adjust_y=text_item.boundingRect().height() / 2) 154 | self.texts.append(shortcut_item) 155 | 156 | start_angle = end_angle 157 | 158 | self.center_item = self.scene.addEllipse( 159 | center.x() - CENTER_RADIUS, center.y() - CENTER_RADIUS, 160 | CENTER_RADIUS * 2, CENTER_RADIUS * 2, 161 | QPen(Qt.NoPen), QBrush(QColor(0, 0, 0, 200)) 162 | ) 163 | 164 | def create_text_item(self, text, font_size, color, position, max_width=60, adjust_y=0): 165 | font = QFont(FONT_FAMILY, font_size, QFont.Bold if font_size == ACTION_FONT_SIZE else QFont.Normal) 166 | text_item = self.scene.addText("") 167 | text_item.setDefaultTextColor(color) 168 | text_item.setFont(font) 169 | self.wrap_text_item(text_item, text, max_width) 170 | text_item.setPos(position.x() - text_item.boundingRect().width() / 2, 171 | position.y() - text_item.boundingRect().height() / 2 + adjust_y) 172 | text_item.setToolTip(text) 173 | return text_item 174 | 175 | def wrap_text_item(self, text_item, text, max_width=60): 176 | font_metrics = QFontMetrics(text_item.font()) 177 | wrapped_text = "" 178 | for line in text.split('\n'): 179 | wrapped_line = "" 180 | for word in line.split(): 181 | test_line = wrapped_line + ("" if not wrapped_line else " ") + word 182 | if font_metrics.width(test_line) > max_width: 183 | wrapped_text += wrapped_line + "\n" 184 | wrapped_line = word 185 | else: 186 | wrapped_line = test_line 187 | wrapped_text += wrapped_line + "\n" 188 | text_item.setPlainText(wrapped_text.strip()) 189 | 190 | def update_selection(self, pos): 191 | center = QPointF(MENU_SIZE / 2, MENU_SIZE / 2) 192 | vector = pos - center 193 | distance = (vector.x()**2 + vector.y()**2)**0.5 194 | 195 | if distance < CENTER_RADIUS: 196 | self.selected = None 197 | self.update_visuals() 198 | return 199 | 200 | angle = math.atan2(-vector.y(), vector.x()) 201 | if angle < 0: 202 | angle += 2 * math.pi 203 | 204 | index = int(len(self.actions) * angle / (2 * math.pi)) 205 | index = (index - 2) % len(self.actions) 206 | 207 | if self.selected != index: 208 | self.selected = index 209 | self.update_visuals() 210 | debug_print(f"Selection updated: {self.actions[self.selected][0]}") 211 | 212 | def update_visuals(self): 213 | for i, slice_item in enumerate(self.slices): 214 | slice_item.is_hovered = (i == self.selected) 215 | slice_item.update() 216 | 217 | self.center_item.setBrush(QBrush(SLICE_HOVER_COLOR if self.selected is not None else QColor(0, 0, 0, 200))) 218 | 219 | def show_at(self, x, y): 220 | self.move(int(x) - MENU_SIZE // 2, int(y) - MENU_SIZE // 2) 221 | QTimer.singleShot(0, self.show) 222 | 223 | def get_selected_action(self): 224 | if self.selected is not None: 225 | debug_print(f"Action selected: {self.actions[self.selected][0]}") 226 | return self.actions[self.selected][1] 227 | debug_print("No action selected") 228 | return None 229 | 230 | def add_action(self, action_name, action_function, shortcut): 231 | self.actions.append((action_name, action_function, shortcut)) 232 | self.draw_menu() 233 | 234 | def remove_action(self, action_name): 235 | self.actions = [action for action in self.actions if action[0] != action_name] 236 | self.draw_menu() 237 | 238 | def reorder_actions(self, new_order): 239 | reordered_actions = [self.actions[i] for i in new_order] 240 | self.actions = reordered_actions 241 | self.draw_menu() 242 | 243 | def select_all(self): 244 | self.perform_keystroke('a') 245 | 246 | def copy(self): 247 | self.perform_keystroke('c') 248 | 249 | def select_all_and_copy(self): 250 | self.select_all() 251 | QTimer.singleShot(100, self.copy) 252 | 253 | def paste(self): 254 | self.perform_keystroke('v') 255 | 256 | def cut(self): 257 | self.perform_keystroke('x') 258 | 259 | def delete(self): 260 | pyautogui.press(DELETE_KEY) 261 | 262 | def next(self): 263 | pyautogui.hotkey('alt', 'right') 264 | 265 | def previous(self): 266 | if IS_MACOS: 267 | pyautogui.keyDown(COMMAND_KEY) 268 | time.sleep(0.1) 269 | pyautogui.press(PREVIOUS_KEY) 270 | pyautogui.keyUp(COMMAND_KEY) 271 | else: 272 | pyautogui.hotkey('alt', PREVIOUS_KEY) 273 | 274 | def undo(self): 275 | self.perform_keystroke('z') 276 | 277 | def redo(self): 278 | if IS_MACOS: 279 | pyautogui.keyDown(COMMAND_KEY) 280 | pyautogui.keyDown('shift') 281 | time.sleep(0.1) 282 | pyautogui.press('z') 283 | pyautogui.keyUp('shift') 284 | pyautogui.keyUp(COMMAND_KEY) 285 | else: 286 | self.perform_keystroke('y') 287 | 288 | def perform_keystroke(self, key): 289 | pyautogui.keyDown(COMMAND_KEY) 290 | time.sleep(0.1) 291 | pyautogui.press(key) 292 | pyautogui.keyUp(COMMAND_KEY) 293 | debug_print(f"Executing: {key.upper()}") 294 | 295 | class EnhancedRightClick(QObject): 296 | def __init__(self): 297 | super().__init__() 298 | self.app = QApplication(sys.argv) 299 | self.radial_menu = RadialMenu() 300 | self.mouse_tracker = GlobalMouseTracker() 301 | self.right_click_start = None 302 | self.is_dragging = False 303 | self.hold_timer = QTimer() 304 | self.hold_timer.setSingleShot(True) 305 | self.hold_threshold = 200 # 200 ms hold threshold 306 | 307 | self.mouse_tracker.mouse_pressed.connect(self.on_mouse_pressed) 308 | self.mouse_tracker.mouse_released.connect(self.on_mouse_released) 309 | self.mouse_tracker.mouse_moved.connect(self.on_mouse_moved) 310 | self.hold_timer.timeout.connect(self.on_hold_timeout) 311 | 312 | def start(self): 313 | debug_print(f"Enhanced Right-Click application started on {'macOS' if IS_MACOS else 'Windows'}") 314 | sys.exit(self.app.exec_()) 315 | 316 | def on_mouse_pressed(self, x, y): 317 | debug_print(f"Mouse pressed at ({x}, {y})") 318 | self.right_click_start = (x, y) 319 | self.hold_timer.start(self.hold_threshold) 320 | 321 | def on_mouse_released(self, x, y): 322 | debug_print(f"Mouse released at ({x}, {y})") 323 | self.hold_timer.stop() 324 | if self.radial_menu.isVisible(): 325 | action = self.radial_menu.get_selected_action() 326 | self.radial_menu.hide() 327 | if action: 328 | QTimer.singleShot(10, action) 329 | self.right_click_start = None 330 | self.is_dragging = False 331 | 332 | def on_mouse_moved(self, x, y): 333 | if self.is_dragging and self.radial_menu.isVisible(): 334 | local_x = x - self.right_click_start[0] + MENU_SIZE // 2 335 | local_y = y - self.right_click_start[1] + MENU_SIZE // 2 336 | self.radial_menu.update_selection(QPointF(local_x, local_y)) 337 | 338 | def on_hold_timeout(self): 339 | if self.right_click_start: 340 | x, y = self.right_click_start 341 | self.is_dragging = True 342 | self.radial_menu.show_at(x, y) 343 | 344 | if __name__ == "__main__": 345 | debug_print(f"Starting Enhanced Right-Click application on {'macOS' if IS_MACOS else 'Windows'}") 346 | app = EnhancedRightClick() 347 | app.radial_menu.add_action("Select All + Copy", app.radial_menu.select_all_and_copy, "Ctrl+A + Ctrl+C") 348 | app.radial_menu.add_action("Redo", app.radial_menu.redo, "Ctrl+Y") 349 | app.radial_menu.add_action("Next", app.radial_menu.next, "Alt+→") 350 | app.radial_menu.add_action("Copy", app.radial_menu.copy, "Ctrl+C") 351 | app.radial_menu.add_action("Paste", app.radial_menu.paste, "Ctrl+V") 352 | app.radial_menu.add_action("Cut", app.radial_menu.cut, "Ctrl+X") 353 | app.radial_menu.add_action("Undo", app.radial_menu.undo, "Ctrl+Z") 354 | app.radial_menu.add_action("Select All", app.radial_menu.select_all, "Ctrl+A") 355 | app.start() 356 | -------------------------------------------------------------------------------- /public/2024-07-1418-29-11-ezgif.com-video-to-gif-converter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tylerbryy/GestureFlow/c695ace4741f6f7d2187f35f85c33cd5df1bc596/public/2024-07-1418-29-11-ezgif.com-video-to-gif-converter.gif -------------------------------------------------------------------------------- /requirements.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tylerbryy/GestureFlow/c695ace4741f6f7d2187f35f85c33cd5df1bc596/requirements.py --------------------------------------------------------------------------------