Skip to content
Merged
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,43 @@
# 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.
- Save/load macros as CSV (name + events JSON). The displayed preview is what is replayed.
- Playback with repeat count (0 = infinite) and immediate stop.

## Structure

```
PyMacroRecorder/
├─ main.py
Expand Down Expand Up @@ -38,6 +66,7 @@ PyMacroRecorder/
```

## Quick start

```bash
python -m venv .venv
source .venv/bin/activate # Windows: `.venv\Scripts\activate`
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"

91 changes: 86 additions & 5 deletions pymacrorecorder/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
"""
Expand All @@ -98,13 +124,25 @@ 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")
self.repeat_var = tk.StringVar(value="1")
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")
Expand All @@ -118,14 +156,25 @@ def _build_ui(self) -> None:
self.preview.pack(fill="both", expand=True)
self.preview.bind("<Delete>", 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"),
Expand Down Expand Up @@ -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:
Comment thread
Redstoneur marked this conversation as resolved.
"""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.
Expand Down
91 changes: 69 additions & 22 deletions pymacrorecorder/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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