diff --git a/README.md b/README.md index c4477e5..e3d4300 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,35 @@ # PyMacroRecorder +--- + +![License](https://img.shields.io/github/license/Redstoneur/PyMacroRecorder) +![Top Language](https://img.shields.io/github/languages/top/Redstoneur/PyMacroRecorder) +![Python Version](https://img.shields.io/badge/python-%3E%3D3.10-blue) +![Size](https://img.shields.io/github/repo-size/Redstoneur/PyMacroRecorder) +![Contributors](https://img.shields.io/github/contributors/Redstoneur/PyMacroRecorder) +![Last Commit](https://img.shields.io/github/last-commit/Redstoneur/PyMacroRecorder) +![Issues](https://img.shields.io/github/issues/Redstoneur/PyMacroRecorder) +![Pull Requests](https://img.shields.io/github/issues-pr/Redstoneur/PyMacroRecorder) + +--- + +![Forks](https://img.shields.io/github/forks/Redstoneur/PyMacroRecorder) +![Stars](https://img.shields.io/github/stars/Redstoneur/PyMacroRecorder) +![Watchers](https://img.shields.io/github/watchers/Redstoneur/PyMacroRecorder) + +--- + +![Latest Release](https://img.shields.io/github/v/release/Redstoneur/PyMacroRecorder) +![Release Date](https://img.shields.io/github/release-date/Redstoneur/PyMacroRecorder) +[![Build Status](https://github.com/Redstoneur/PyMacroRecorder/actions/workflows/ci.yml/badge.svg)](https://github.com/Redstoneur/PyMacroRecorder/actions/workflows/ci.yml) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/1626228dee914e71b2805544b1b5094d)](https://app.codacy.com/gh/Redstoneur/PyMacroRecorder/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) + +--- + Tkinter app to record, play, and save keyboard/mouse macros. ## Features + - Global recording (pynput) with live log. - Buttons: Start/Stop Record, Start/Stop Macro, Save Macro, Load Macro. - Configurable global hotkeys (minimum 2 keys). Control combos are ignored during recording. @@ -10,6 +37,7 @@ Tkinter app to record, play, and save keyboard/mouse macros. - Playback with repeat count (0 = infinite) and immediate stop. ## Structure + ``` PyMacroRecorder/ ├─ main.py @@ -38,6 +66,7 @@ PyMacroRecorder/ ``` ## Quick start + ```bash python -m venv .venv source .venv/bin/activate # Windows: `.venv\Scripts\activate` @@ -46,10 +75,12 @@ python main.py ``` ## Save & config + - Macros: CSV chosen via the UI (columns `name`, `events`). - Hotkeys: `config.json` in the user data directory (see `pymacrorecorder/config.py`). ## Default hotkeys + - Start Record: Ctrl+Alt+R - Stop Record: Ctrl+Alt+S - Start Macro: Ctrl+Alt+P diff --git a/build.ps1 b/build.ps1 index bd063f4..47da00f 100644 --- a/build.ps1 +++ b/build.ps1 @@ -25,5 +25,5 @@ pyinstaller --onefile --noconsole ` --workpath "$BuildDir" ` "$EntryPoint" -Write-Host "Build complete. Binary located at: $DistDir\$AppName.exe" +Write-Output "Build complete. Binary located at: $DistDir\$AppName.exe" diff --git a/pymacrorecorder/app.py b/pymacrorecorder/app.py index 74c85d6..2eb862e 100644 --- a/pymacrorecorder/app.py +++ b/pymacrorecorder/app.py @@ -36,6 +36,20 @@ def __init__(self) -> None: self.player = Player(self._log, on_completion=self._on_playback_complete) self.current_macro: Optional[Macro] = None + # Initialize UI attributes + self.start_rec_btn: ttk.Button = None # type: ignore + self.stop_rec_btn: ttk.Button = None # type: ignore + self.start_play_btn: ttk.Button = None # type: ignore + self.stop_play_btn: ttk.Button = None # type: ignore + self.save_btn: ttk.Button = None # type: ignore + self.load_btn: ttk.Button = None # type: ignore + self.delete_btn: ttk.Button = None # type: ignore + self.repeat_var: tk.StringVar = None # type: ignore + self.repeat_entry: ttk.Entry = None # type: ignore + self.preview: ttk.Treeview = None # type: ignore + self.log_text: tk.Text = None # type: ignore + self.hotkey_labels: Dict[str, tk.Label] = {} + self._build_ui() self._refresh_hotkey_labels() self.hotkey_manager = HotkeyManager(self.hotkeys, self._dispatch_hotkey) @@ -73,6 +87,18 @@ def _set_icon(self) -> None: def _build_ui(self) -> None: """Build the control bar, preview tree, log area, and hotkey editor. + :return: Nothing. + :rtype: None + """ + self._build_control_bar() + self._build_repeat_frame() + self._build_preview_tree() + self._build_log_area() + self._build_hotkey_editor() + + def _build_control_bar(self) -> None: + """Build the control bar with recording, playback, and file buttons. + :return: Nothing. :rtype: None """ @@ -98,6 +124,12 @@ def _build_ui(self) -> None: self.load_btn.grid(row=0, column=5, padx=5, pady=2) self.delete_btn.grid(row=0, column=6, padx=5, pady=2) + def _build_repeat_frame(self) -> None: + """Build the repeat count input frame. + + :return: Nothing. + :rtype: None + """ repeat_frame = ttk.Frame(self) repeat_frame.pack(fill="x", padx=10, pady=2) ttk.Label(repeat_frame, text="Repeats (0 = infinite):").pack(side="left") @@ -105,6 +137,12 @@ def _build_ui(self) -> None: self.repeat_entry = ttk.Entry(repeat_frame, textvariable=self.repeat_var, width=6) self.repeat_entry.pack(side="left", padx=5) + def _build_preview_tree(self) -> None: + """Build the event preview tree widget. + + :return: Nothing. + :rtype: None + """ preview_frame = ttk.LabelFrame(self, text="Event preview") preview_frame.pack(fill="both", expand=True, padx=10, pady=5) columns = ("#", "type", "details", "delay") @@ -118,14 +156,25 @@ def _build_ui(self) -> None: self.preview.pack(fill="both", expand=True) self.preview.bind("", lambda e: self._delete_selected_events()) + def _build_log_area(self) -> None: + """Build the log text area. + + :return: Nothing. + :rtype: None + """ log_frame = ttk.LabelFrame(self, text="Log") log_frame.pack(fill="both", expand=True, padx=10, pady=5) self.log_text = tk.Text(log_frame, height=8, state="disabled") self.log_text.pack(fill="both", expand=True) + def _build_hotkey_editor(self) -> None: + """Build the hotkey configuration editor. + + :return: Nothing. + :rtype: None + """ hotkey_frame = ttk.LabelFrame(self, text="Hotkeys") hotkey_frame.pack(fill="x", padx=10, pady=5) - self.hotkey_labels: Dict[str, tk.Label] = {} row = 0 for action, label in [ ("start_record", "Start Record"), @@ -248,23 +297,55 @@ def _delete_selected_events(self, _event: Optional[tk.Event] = None) -> None: :return: Nothing. :rtype: None """ - if not self.current_macro or self.current_macro.is_empty(): - self._log("No macro to edit") + if not self._can_delete_events(): return selection = list(self.preview.selection()) if not selection: self._log("No rows selected for deletion") return + self._perform_deletion(selection) + + def _can_delete_events(self) -> bool: + """Check if deletion is possible. + + :return: ``True`` if macro exists and is not empty. + :rtype: bool + """ + if not self.current_macro or self.current_macro.is_empty(): + self._log("No macro to edit") + return False + return True + + def _perform_deletion(self, selection: List[str]) -> None: + """Remove events at the selected indices and update preview. + + :param selection: List of selected item IDs from the tree view. + :type selection: list[str] + :return: Nothing. + :rtype: None + """ # Remove events in reverse order to keep indexes stable while popping indexes = sorted((self.preview.index(item) for item in selection), reverse=True) + deleted_count = 0 for idx in indexes: if 0 <= idx < len(self.current_macro.events): self.current_macro.events.pop(idx) + deleted_count += 1 + self._log_deletion_result(deleted_count) + self._populate_preview(self.current_macro if not self.current_macro.is_empty() else None) + + def _log_deletion_result(self, count: int) -> None: + """Log the deletion result. + + :param count: Number of events deleted. + :type count: int + :return: Nothing. + :rtype: None + """ if self.current_macro.is_empty(): self._log("All events deleted from macro") else: - self._log(f"Deleted {len(indexes)} event(s) from macro") - self._populate_preview(self.current_macro if not self.current_macro.is_empty() else None) + self._log(f"Deleted {count} event(s) from macro") def start_playback(self) -> None: """Start macro playback with the configured repeat count. diff --git a/pymacrorecorder/player.py b/pymacrorecorder/player.py index c498be3..793145a 100644 --- a/pymacrorecorder/player.py +++ b/pymacrorecorder/player.py @@ -107,25 +107,72 @@ def _apply_event(self, event) -> None: :return: Nothing. :rtype: None """ - etype = event.event_type - data = event.payload - if etype == "key_down": - self._keyboard.press(str_to_key(data.get("key", ""))) - elif etype == "key_up": - self._keyboard.release(str_to_key(data.get("key", ""))) - elif etype == "mouse_click": - button = str_to_button(data.get("button", "left")) - action = data.get("action", "press") - x = data.get("x") - y = data.get("y") - if x is not None and y is not None: - self._mouse.position = (x, y) - if action == "press": - self._mouse.press(button) - else: - self._mouse.release(button) - elif etype == "mouse_scroll": - self._mouse.position = (data.get("x", 0), data.get("y", 0)) - self._mouse.scroll(data.get("dx", 0), data.get("dy", 0)) - elif etype == "mouse_move": - self._mouse.position = (data.get("x", 0), data.get("y", 0)) + handlers: dict[str, Callable[[dict], None]] = { + "key_down": self._handle_key_down, + "key_up": self._handle_key_up, + "mouse_click": self._handle_mouse_click, + "mouse_scroll": self._handle_mouse_scroll, + "mouse_move": self._handle_mouse_move, + } + handler: Optional[Callable[[dict], None]] = handlers.get(event.event_type) + if handler: + handler(event.payload) + def _handle_key_down(self, data: dict) -> None: + """Handle key down event. + + :param data: Event payload. + :type data: dict + :return: Nothing. + :rtype: None + """ + self._keyboard.press(str_to_key(data.get("key", ""))) + + def _handle_key_up(self, data: dict) -> None: + """Handle key up event. + + :param data: Event payload. + :type data: dict + :return: Nothing. + :rtype: None + """ + self._keyboard.release(str_to_key(data.get("key", ""))) + + def _handle_mouse_click(self, data: dict) -> None: + """Handle mouse click event. + + :param data: Event payload. + :type data: dict + :return: Nothing. + :rtype: None + """ + button = str_to_button(data.get("button", "left")) + action = data.get("action", "press") + x = data.get("x") + y = data.get("y") + if x is not None and y is not None: + self._mouse.position = (x, y) + if action == "press": + self._mouse.press(button) + else: + self._mouse.release(button) + + def _handle_mouse_scroll(self, data: dict) -> None: + """Handle mouse scroll event. + + :param data: Event payload. + :type data: dict + :return: Nothing. + :rtype: None + """ + self._mouse.position = (data.get("x", 0), data.get("y", 0)) + self._mouse.scroll(data.get("dx", 0), data.get("dy", 0)) + + def _handle_mouse_move(self, data: dict) -> None: + """Handle mouse move event. + + :param data: Event payload. + :type data: dict + :return: Nothing. + :rtype: None + """ + self._mouse.position = (data.get("x", 0), data.get("y", 0)) diff --git a/requirements.txt b/requirements.txt index f0465ab..662140d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ pynput>=1.7.6 appdirs>=1.4.4 -pyinstaller>=5.1.0 -pyinstaller-hooks-contrib>=2024.6.0 +pyinstaller>=6.19.0 +pyinstaller-hooks-contrib>=2026.0 pylint>=2.15.0 ruff>=0.0.241 pytest>=7.2.0