From 7683cd0bf8b0f2a4ee6a19b070ccebdaab35ebea Mon Sep 17 00:00:00 2001 From: Redstoneur Date: Wed, 18 Feb 2026 09:01:29 +0100 Subject: [PATCH 01/11] Add status badges and metrics to README Adds a collection of shields and badges to the README to surface repository metadata and CI/quality status. Badges include license, top language, Python version (3.14.0), repo size, contributors, last commit, issues, pull requests, forks, stars, watchers, latest release and release date, plus CI build and Codacy grade. Visual separators (---) were added to group badge sections and improve header presentation. --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index c4477e5..e1e7e82 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,31 @@ # 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-3.14.0-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 From a1bf19888dc4c6f5c8818e6857e4a02388ba3d96 Mon Sep 17 00:00:00 2001 From: Redstoneur Date: Wed, 18 Feb 2026 09:04:40 +0100 Subject: [PATCH 02/11] Bump PyInstaller and hooks versions Update minimum versions in requirements.txt: pyinstaller upgraded to >=6.19.0 and pyinstaller-hooks-contrib to >=2026.0. This ensures the project uses newer PyInstaller tooling and hook updates; other requirements remain unchanged. --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 7756c1a791453501455b60b2412d1517e521db16 Mon Sep 17 00:00:00 2001 From: Redstoneur Date: Wed, 18 Feb 2026 09:08:58 +0100 Subject: [PATCH 03/11] Use Write-Output instead of Write-Host in build script Replace Write-Host with Write-Output in build.ps1 so the final build message goes to the PowerShell pipeline/standard output rather than directly to the host. This allows the message to be redirected or captured (useful for CI/logging) without changing the reported path or other behavior. --- build.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 6430b1a522829c180fa6ede3946a4b56c168be04 Mon Sep 17 00:00:00 2001 From: Redstoneur Date: Wed, 18 Feb 2026 09:10:42 +0100 Subject: [PATCH 04/11] Improve README spacing and formatting Add blank lines around headings, lists, and code blocks in README.md to improve readability and visual separation between sections (Features, Structure, Quick start, Save & config, Default hotkeys). No functional content changes. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index e1e7e82..cbb27b7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ 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. @@ -36,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 @@ -64,6 +66,7 @@ PyMacroRecorder/ ``` ## Quick start + ```bash python -m venv .venv source .venv/bin/activate # Windows: `.venv\Scripts\activate` @@ -72,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 From 8500719fbc6ce0e00de4741629ea61fb68af510d Mon Sep 17 00:00:00 2001 From: Redstoneur Date: Wed, 18 Feb 2026 09:20:43 +0100 Subject: [PATCH 05/11] Refactor event handling and deletion logic Extract helper methods in App to handle deletion checks, perform deletion and log results (_can_delete_events, _perform_deletion, _log_deletion_result). This centralizes deletion flow, clarifies logging, and ensures preview is updated after deletions. Replace large if/elif block in Player with a handler dispatch and separate methods for each event type (_handle_key_down, _handle_key_up, _handle_mouse_click, _handle_mouse_scroll, _handle_mouse_move) to improve readability and maintainability without changing behavior. --- pymacrorecorder/app.py | 38 ++++++++++++++-- pymacrorecorder/player.py | 92 +++++++++++++++++++++++++++++---------- 2 files changed, 104 insertions(+), 26 deletions(-) diff --git a/pymacrorecorder/app.py b/pymacrorecorder/app.py index 74c85d6..25727e1 100644 --- a/pymacrorecorder/app.py +++ b/pymacrorecorder/app.py @@ -248,23 +248,53 @@ 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) for idx in indexes: if 0 <= idx < len(self.current_macro.events): self.current_macro.events.pop(idx) + self._log_deletion_result(len(indexes)) + 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..94582f0 100644 --- a/pymacrorecorder/player.py +++ b/pymacrorecorder/player.py @@ -107,25 +107,73 @@ 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 = { + "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 = 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)) From 81406d3027c1ebd3c56583fba68f506f6feb35bd Mon Sep 17 00:00:00 2001 From: Redstoneur Date: Wed, 18 Feb 2026 09:26:37 +0100 Subject: [PATCH 06/11] Add explicit type annotation for handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotate the local `handler` variable in Player.player to provide an explicit Callable union type when retrieving from the handlers mapping. This is a typing-only change to satisfy the type checker; there is no runtime behavioral change—handler is still looked up and invoked if present. --- pymacrorecorder/player.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pymacrorecorder/player.py b/pymacrorecorder/player.py index 94582f0..acccfa1 100644 --- a/pymacrorecorder/player.py +++ b/pymacrorecorder/player.py @@ -114,7 +114,8 @@ def _apply_event(self, event) -> None: "mouse_scroll": self._handle_mouse_scroll, "mouse_move": self._handle_mouse_move, } - handler = handlers.get(event.event_type) + handler: Callable[..., None] | Callable[..., None] | Callable[..., None] | Callable[ + ..., None] | Callable[..., None] | None = handlers.get(event.event_type) if handler: handler(event.payload) From 209b07e5efe9ead90176bd66cba06a4c17bcb670 Mon Sep 17 00:00:00 2001 From: Redstoneur Date: Wed, 18 Feb 2026 09:34:47 +0100 Subject: [PATCH 07/11] Refactor UI build into modular submethods Split App._build_ui into smaller helper methods (_build_control_bar, _build_repeat_frame, _build_preview_tree, _build_log_area, _build_hotkey_editor) and added docstrings/return type hints. This improves readability and separates construction of the control bar, repeat input, preview tree, log area, and hotkey editor without changing behavior. --- pymacrorecorder/app.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/pymacrorecorder/app.py b/pymacrorecorder/app.py index 25727e1..9f6bd90 100644 --- a/pymacrorecorder/app.py +++ b/pymacrorecorder/app.py @@ -73,6 +73,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 +110,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 +123,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,11 +142,23 @@ 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] = {} From e9ac1293a13747b3667b828d0faafb7b3291a65e Mon Sep 17 00:00:00 2001 From: Redstoneur Date: Wed, 18 Feb 2026 09:52:23 +0100 Subject: [PATCH 08/11] Initialize UI attributes and hotkey labels Explicitly initialize UI widget attributes (buttons, entry, treeview, log_text, repeat_var) and hotkey_labels in App.__init__ with type hints to avoid mypy/pylint complaints and potential attribute errors. Removed the duplicate hotkey_labels initialization from the hotkey frame builder. No functional behavior changes intended; this is a static typing / robustness cleanup. --- pymacrorecorder/app.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/pymacrorecorder/app.py b/pymacrorecorder/app.py index 9f6bd90..115a2af 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) @@ -161,7 +175,6 @@ def _build_hotkey_editor(self) -> 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"), From 680378b42b2ebfcc011af0c1274d7a570620df41 Mon Sep 17 00:00:00 2001 From: Redstoneur <84982695+Redstoneur@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:54:58 +0100 Subject: [PATCH 09/11] Add precise type hints to Player handlers Annotate handlers in Player with dict[str, Callable[[dict], None]] and replace the previous long union type for handler with Optional[Callable[[dict], None]]. This clarifies the expected payload signature, improves readability and static type checking without changing runtime behavior. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pymacrorecorder/player.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pymacrorecorder/player.py b/pymacrorecorder/player.py index acccfa1..793145a 100644 --- a/pymacrorecorder/player.py +++ b/pymacrorecorder/player.py @@ -107,18 +107,16 @@ def _apply_event(self, event) -> None: :return: Nothing. :rtype: None """ - handlers = { + 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: Callable[..., None] | Callable[..., None] | Callable[..., None] | Callable[ - ..., None] | Callable[..., None] | None = handlers.get(event.event_type) + 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. From 4dd2e91129358d2ac423b60effabf55fc244e0c8 Mon Sep 17 00:00:00 2001 From: Redstoneur <84982695+Redstoneur@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:58:12 +0100 Subject: [PATCH 10/11] Update Python version badge in README Change the README Python badge from a specific 3.14.0 value to a minimum requirement of >=3.10 to reflect supported Python versions. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cbb27b7..e3d4300 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![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-3.14.0-blue) +![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) From 1c84b8d62cdfadc473c2f999a962181bc3cb7474 Mon Sep 17 00:00:00 2001 From: Redstoneur Date: Wed, 18 Feb 2026 10:01:45 +0100 Subject: [PATCH 11/11] Log actual number of deleted events Fix deletion logging when removing selected events from the preview. Previously the code logged len(indexes) which could count selections that were out-of-range and not actually removed. This change adds a deleted_count that is incremented only when an event is popped and passes that value to _log_deletion_result so the logged count matches the real number of deletions. --- pymacrorecorder/app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pymacrorecorder/app.py b/pymacrorecorder/app.py index 115a2af..2eb862e 100644 --- a/pymacrorecorder/app.py +++ b/pymacrorecorder/app.py @@ -326,10 +326,12 @@ def _perform_deletion(self, selection: List[str]) -> 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) - self._log_deletion_result(len(indexes)) + 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: