Modernize application to v0.9.0 with PySide6, FastAPI, and PWA support
## Major Changes ### Build System - Replace requirements.txt with pyproject.toml for modern dependency management - Support for uv package manager alongside pip - Update PyInstaller spec files for new dependencies and structure ### Desktop GUI (Tkinter → PySide6) - Complete rewrite of UI using PySide6/Qt6 - New modular structure in gui/ directory: - main_window.py: Main application window - macro_editor.py: Macro creation/editing dialog - command_builder.py: Visual command sequence builder - Modern dark theme with consistent styling - System tray integration ### Web Server (Flask → FastAPI) - Migrate from Flask/Waitress to FastAPI/Uvicorn - Add WebSocket support for real-time updates - Full CRUD API for macro management - Image upload endpoint ### Web Interface → PWA - New web/ directory with standalone static files - PWA manifest and service worker for installability - Offline caching support - Full macro editing from web interface - Responsive mobile-first design - Command builder UI matching desktop functionality ### Macro System Enhancement - New command sequence model replacing simple text/app types - Command types: text, key, hotkey, wait, app - Support for delays between commands (wait in ms) - Support for key presses between commands (enter, tab, etc.) - Automatic migration of existing macros to new format - Backward compatibility maintained ### Files Added - pyproject.toml - gui/__init__.py, main_window.py, macro_editor.py, command_builder.py - gui/widgets/__init__.py - web/index.html, manifest.json, service-worker.js - web/css/styles.css, web/js/app.js - web/icons/icon-192.png, icon-512.png ### Files Removed - requirements.txt (replaced by pyproject.toml) - ui_components.py (replaced by gui/ modules) - web_templates.py (replaced by web/ static files) - main.spec (consolidated into platform-specific specs) ### Files Modified - main.py: Simplified entry point for PySide6 - macro_manager.py: Command sequence model and migration - web_server.py: FastAPI implementation - config.py: Version bump to 0.9.0 - All .spec files: Updated for PySide6 and new structure - README.md: Complete rewrite for v0.9.0 - .gitea/workflows/release.yml: Disabled pending build testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
394
macro_manager.py
394
macro_manager.py
@@ -1,4 +1,4 @@
|
||||
# Macro management and execution
|
||||
# Macro management and execution with command sequence support
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -7,238 +7,344 @@ import pyautogui
|
||||
import subprocess
|
||||
import time
|
||||
from PIL import Image
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class MacroManager:
|
||||
def __init__(self, data_file, images_dir, app_dir):
|
||||
"""Manages macro storage, migration, and execution with command sequences."""
|
||||
|
||||
def __init__(self, data_file: str, images_dir: str, app_dir: str):
|
||||
self.data_file = data_file
|
||||
self.images_dir = images_dir
|
||||
self.app_dir = app_dir
|
||||
self.macros = {}
|
||||
self.load_macros()
|
||||
|
||||
|
||||
def load_macros(self):
|
||||
"""Load macros from JSON file"""
|
||||
"""Load macros from JSON file and migrate if needed."""
|
||||
try:
|
||||
if os.path.exists(self.data_file):
|
||||
with open(self.data_file, "r") as file:
|
||||
self.macros = json.load(file)
|
||||
|
||||
# Migrate old format macros
|
||||
migrated = False
|
||||
for macro_id, macro in list(self.macros.items()):
|
||||
if macro.get("type") != "sequence":
|
||||
self.macros[macro_id] = self._migrate_macro(macro)
|
||||
migrated = True
|
||||
|
||||
if migrated:
|
||||
self.save_macros()
|
||||
print("Migrated macros to new command sequence format")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading macros: {e}")
|
||||
self.macros = {}
|
||||
|
||||
def _migrate_macro(self, old_macro: dict) -> dict:
|
||||
"""Convert old macro format to new command sequence format."""
|
||||
if old_macro.get("type") == "sequence":
|
||||
return old_macro
|
||||
|
||||
commands = []
|
||||
modifiers = old_macro.get("modifiers", {})
|
||||
|
||||
if old_macro.get("type") == "text":
|
||||
# Build held keys list
|
||||
held_keys = []
|
||||
for mod in ["ctrl", "alt", "shift"]:
|
||||
if modifiers.get(mod):
|
||||
held_keys.append(mod)
|
||||
|
||||
if held_keys:
|
||||
# Use hotkey for modified text
|
||||
commands.append({
|
||||
"type": "hotkey",
|
||||
"keys": held_keys + [old_macro.get("command", "")]
|
||||
})
|
||||
else:
|
||||
# Plain text
|
||||
commands.append({
|
||||
"type": "text",
|
||||
"value": old_macro.get("command", "")
|
||||
})
|
||||
|
||||
# Add enter if requested
|
||||
if modifiers.get("enter"):
|
||||
commands.append({"type": "key", "value": "enter"})
|
||||
|
||||
elif old_macro.get("type") == "app":
|
||||
commands.append({
|
||||
"type": "app",
|
||||
"command": old_macro.get("command", "")
|
||||
})
|
||||
|
||||
return {
|
||||
"name": old_macro.get("name", "Unnamed"),
|
||||
"type": "sequence",
|
||||
"commands": commands,
|
||||
"category": old_macro.get("category", ""),
|
||||
"image_path": old_macro.get("image_path", ""),
|
||||
"last_used": old_macro.get("last_used", 0)
|
||||
}
|
||||
|
||||
def save_macros(self):
|
||||
"""Save macros to JSON file"""
|
||||
"""Save macros to JSON file."""
|
||||
try:
|
||||
with open(self.data_file, "w") as file:
|
||||
json.dump(self.macros, file, indent=4)
|
||||
except Exception as e:
|
||||
print(f"Error saving macros: {e}")
|
||||
|
||||
def get_sorted_macros(self, sort_by="name"):
|
||||
"""Get macros sorted by specified criteria"""
|
||||
def get_sorted_macros(self, sort_by: str = "name"):
|
||||
"""Get macros sorted by specified criteria."""
|
||||
macro_list = list(self.macros.items())
|
||||
|
||||
|
||||
if sort_by == "name":
|
||||
macro_list.sort(key=lambda x: x[1]["name"].lower())
|
||||
elif sort_by == "type":
|
||||
macro_list.sort(key=lambda x: (x[1].get("type", ""), x[1]["name"].lower()))
|
||||
# Sort by first command type in sequence
|
||||
def get_first_type(macro):
|
||||
cmds = macro.get("commands", [])
|
||||
return cmds[0].get("type", "") if cmds else ""
|
||||
macro_list.sort(key=lambda x: (get_first_type(x[1]), x[1]["name"].lower()))
|
||||
elif sort_by == "recent":
|
||||
# Sort by last_used timestamp if available, otherwise by name
|
||||
macro_list.sort(key=lambda x: (x[1].get("last_used", 0), x[1]["name"].lower()), reverse=True)
|
||||
|
||||
macro_list.sort(
|
||||
key=lambda x: (x[1].get("last_used", 0), x[1]["name"].lower()),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
return macro_list
|
||||
|
||||
def filter_macros_by_tab(self, macro_list, tab_name):
|
||||
"""Filter macros based on tab name"""
|
||||
def filter_macros_by_tab(self, macro_list: list, tab_name: str):
|
||||
"""Filter macros based on tab/category name."""
|
||||
if tab_name == "All":
|
||||
return macro_list
|
||||
|
||||
|
||||
filtered = []
|
||||
for macro_id, macro in macro_list:
|
||||
# Check type match
|
||||
if macro.get("type", "").title() == tab_name:
|
||||
# Check category match
|
||||
if macro.get("category") == tab_name:
|
||||
filtered.append((macro_id, macro))
|
||||
# Check custom category match
|
||||
elif macro.get("category") == tab_name:
|
||||
filtered.append((macro_id, macro))
|
||||
|
||||
# Check first command type match
|
||||
elif macro.get("commands"):
|
||||
first_type = macro["commands"][0].get("type", "").title()
|
||||
if first_type == tab_name:
|
||||
filtered.append((macro_id, macro))
|
||||
|
||||
return filtered
|
||||
|
||||
def get_unique_tabs(self):
|
||||
"""Get list of unique tabs based on macro types and categories"""
|
||||
def get_unique_tabs(self) -> list:
|
||||
"""Get list of unique tabs based on categories."""
|
||||
tabs = ["All"]
|
||||
unique_types = set()
|
||||
|
||||
categories = set()
|
||||
|
||||
for macro in self.macros.values():
|
||||
if macro.get("type"):
|
||||
unique_types.add(macro["type"].title())
|
||||
if macro.get("category"):
|
||||
unique_types.add(macro["category"])
|
||||
|
||||
for tab_type in sorted(unique_types):
|
||||
if tab_type not in ["All"]:
|
||||
tabs.append(tab_type)
|
||||
|
||||
categories.add(macro["category"])
|
||||
|
||||
for category in sorted(categories):
|
||||
if category and category not in tabs:
|
||||
tabs.append(category)
|
||||
|
||||
return tabs
|
||||
|
||||
def add_macro(self, name, macro_type, command, category="", modifiers=None, image_path=""):
|
||||
"""Add a new macro"""
|
||||
if modifiers is None:
|
||||
modifiers = {"ctrl": False, "alt": False, "shift": False, "enter": False}
|
||||
|
||||
def add_macro(
|
||||
self,
|
||||
name: str,
|
||||
commands: list,
|
||||
category: str = "",
|
||||
image_path: str = ""
|
||||
) -> str:
|
||||
"""Add a new macro with command sequence."""
|
||||
macro_id = str(uuid.uuid4())
|
||||
|
||||
|
||||
# Process image if provided
|
||||
image_path_reference = ""
|
||||
if image_path:
|
||||
try:
|
||||
# Generate unique filename for the image
|
||||
file_ext = os.path.splitext(image_path)[1].lower()
|
||||
unique_filename = f"{uuid.uuid4().hex}{file_ext}"
|
||||
dest_path = os.path.join(self.images_dir, unique_filename)
|
||||
|
||||
# Resize image to max 256x256
|
||||
with Image.open(image_path) as img:
|
||||
img.thumbnail((256, 256))
|
||||
img.save(dest_path)
|
||||
|
||||
# Store the relative path to the image
|
||||
image_path_reference = os.path.join("macro_images", unique_filename)
|
||||
except Exception as e:
|
||||
print(f"Error processing image: {e}")
|
||||
image_path_reference = self._process_image(image_path) if image_path else ""
|
||||
|
||||
# Create macro data
|
||||
macro_data = {
|
||||
"name": name,
|
||||
"type": macro_type,
|
||||
"command": command,
|
||||
"type": "sequence",
|
||||
"commands": commands,
|
||||
"category": category,
|
||||
"image_path": image_path_reference,
|
||||
"modifiers": modifiers,
|
||||
"last_used": 0
|
||||
}
|
||||
|
||||
if category:
|
||||
macro_data["category"] = category
|
||||
|
||||
self.macros[macro_id] = macro_data
|
||||
self.save_macros()
|
||||
return macro_id
|
||||
|
||||
def update_macro(self, macro_id, name, macro_type, command, category="", modifiers=None, image_path=""):
|
||||
"""Update an existing macro"""
|
||||
def update_macro(
|
||||
self,
|
||||
macro_id: str,
|
||||
name: str,
|
||||
commands: list,
|
||||
category: str = "",
|
||||
image_path: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Update an existing macro."""
|
||||
if macro_id not in self.macros:
|
||||
return False
|
||||
|
||||
if modifiers is None:
|
||||
modifiers = {"ctrl": False, "alt": False, "shift": False, "enter": False}
|
||||
|
||||
|
||||
macro = self.macros[macro_id]
|
||||
|
||||
# Keep the old image or update with new one
|
||||
image_path_reference = macro.get("image_path", "")
|
||||
if image_path:
|
||||
try:
|
||||
# Generate unique filename for the image
|
||||
file_ext = os.path.splitext(image_path)[1].lower()
|
||||
unique_filename = f"{uuid.uuid4().hex}{file_ext}"
|
||||
dest_path = os.path.join(self.images_dir, unique_filename)
|
||||
|
||||
# Resize image to max 256x256
|
||||
with Image.open(image_path) as img:
|
||||
img.thumbnail((256, 256))
|
||||
img.save(dest_path)
|
||||
|
||||
# Store the relative path to the image
|
||||
image_path_reference = os.path.join("macro_images", unique_filename)
|
||||
except Exception as e:
|
||||
print(f"Error processing image: {e}")
|
||||
|
||||
# Keep old image or update with new one
|
||||
if image_path is not None:
|
||||
image_path_reference = self._process_image(image_path) if image_path else ""
|
||||
else:
|
||||
image_path_reference = macro.get("image_path", "")
|
||||
|
||||
# Update macro data
|
||||
updated_macro = {
|
||||
self.macros[macro_id] = {
|
||||
"name": name,
|
||||
"type": macro_type,
|
||||
"command": command,
|
||||
"type": "sequence",
|
||||
"commands": commands,
|
||||
"category": category,
|
||||
"image_path": image_path_reference,
|
||||
"modifiers": modifiers,
|
||||
"last_used": macro.get("last_used", 0)
|
||||
}
|
||||
|
||||
if category:
|
||||
updated_macro["category"] = category
|
||||
|
||||
self.macros[macro_id] = updated_macro
|
||||
|
||||
self.save_macros()
|
||||
return True
|
||||
|
||||
def delete_macro(self, macro_id):
|
||||
"""Delete a macro"""
|
||||
def _process_image(self, image_path: str) -> str:
|
||||
"""Process and store an image for a macro."""
|
||||
if not image_path:
|
||||
return ""
|
||||
|
||||
try:
|
||||
file_ext = os.path.splitext(image_path)[1].lower()
|
||||
unique_filename = f"{uuid.uuid4().hex}{file_ext}"
|
||||
dest_path = os.path.join(self.images_dir, unique_filename)
|
||||
|
||||
# Resize image to max 256x256
|
||||
with Image.open(image_path) as img:
|
||||
img.thumbnail((256, 256))
|
||||
img.save(dest_path)
|
||||
|
||||
return os.path.join("macro_images", unique_filename)
|
||||
except Exception as e:
|
||||
print(f"Error processing image: {e}")
|
||||
return ""
|
||||
|
||||
def delete_macro(self, macro_id: str) -> bool:
|
||||
"""Delete a macro."""
|
||||
if macro_id not in self.macros:
|
||||
return False
|
||||
|
||||
|
||||
macro = self.macros[macro_id]
|
||||
|
||||
# Delete associated image file if it exists
|
||||
if "image_path" in macro and macro["image_path"]:
|
||||
|
||||
# Delete associated image file
|
||||
if macro.get("image_path"):
|
||||
try:
|
||||
img_path = os.path.join(self.app_dir, macro["image_path"])
|
||||
if os.path.exists(img_path):
|
||||
os.remove(img_path)
|
||||
except Exception as e:
|
||||
print(f"Error removing image file: {e}")
|
||||
|
||||
|
||||
del self.macros[macro_id]
|
||||
self.save_macros()
|
||||
return True
|
||||
|
||||
def execute_macro(self, macro_id):
|
||||
"""Execute a macro by ID"""
|
||||
def execute_macro(self, macro_id: str) -> bool:
|
||||
"""Execute a macro by ID."""
|
||||
if macro_id not in self.macros:
|
||||
return False
|
||||
|
||||
macro = self.macros[macro_id]
|
||||
|
||||
# Update last_used timestamp for recent sorting
|
||||
|
||||
# Update last_used timestamp
|
||||
self.macros[macro_id]["last_used"] = time.time()
|
||||
self.save_macros()
|
||||
|
||||
|
||||
try:
|
||||
if macro["type"] == "text":
|
||||
# Handle key modifiers
|
||||
modifiers = macro.get("modifiers", {})
|
||||
|
||||
# Add modifier keys if enabled
|
||||
if modifiers.get("ctrl", False):
|
||||
pyautogui.keyDown('ctrl')
|
||||
if modifiers.get("alt", False):
|
||||
pyautogui.keyDown('alt')
|
||||
if modifiers.get("shift", False):
|
||||
pyautogui.keyDown('shift')
|
||||
|
||||
# Handle single character vs multi-character commands
|
||||
if str(macro["command"]) and len(str(macro["command"])) == 1:
|
||||
pyautogui.keyDown(macro["command"])
|
||||
time.sleep(0.5)
|
||||
pyautogui.keyUp(macro["command"])
|
||||
else:
|
||||
pyautogui.typewrite(macro["command"], interval=0.02)
|
||||
|
||||
# Release modifier keys in reverse order
|
||||
if modifiers.get("shift", False):
|
||||
pyautogui.keyUp('shift')
|
||||
if modifiers.get("alt", False):
|
||||
pyautogui.keyUp('alt')
|
||||
if modifiers.get("ctrl", False):
|
||||
pyautogui.keyUp('ctrl')
|
||||
|
||||
# Add Enter/Return if requested
|
||||
if modifiers.get("enter", False):
|
||||
pyautogui.press('enter')
|
||||
|
||||
elif macro["type"] == "app":
|
||||
subprocess.Popen(macro["command"], shell=True)
|
||||
|
||||
commands = macro.get("commands", [])
|
||||
for cmd in commands:
|
||||
self._execute_command(cmd)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error executing macro: {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
def _execute_command(self, cmd: dict):
|
||||
"""Execute a single command from a sequence."""
|
||||
cmd_type = cmd.get("type", "")
|
||||
|
||||
if cmd_type == "text":
|
||||
# Type text string
|
||||
value = cmd.get("value", "")
|
||||
if value:
|
||||
if len(value) == 1:
|
||||
pyautogui.press(value)
|
||||
else:
|
||||
pyautogui.typewrite(value, interval=0.02)
|
||||
|
||||
elif cmd_type == "key":
|
||||
# Press a single key
|
||||
key = cmd.get("value", "")
|
||||
if key:
|
||||
pyautogui.press(key)
|
||||
|
||||
elif cmd_type == "hotkey":
|
||||
# Press key combination
|
||||
keys = cmd.get("keys", [])
|
||||
if keys:
|
||||
pyautogui.hotkey(*keys)
|
||||
|
||||
elif cmd_type == "wait":
|
||||
# Delay in milliseconds
|
||||
ms = cmd.get("ms", 0)
|
||||
if ms > 0:
|
||||
time.sleep(ms / 1000.0)
|
||||
|
||||
elif cmd_type == "app":
|
||||
# Launch application
|
||||
command = cmd.get("command", "")
|
||||
if command:
|
||||
subprocess.Popen(command, shell=True)
|
||||
|
||||
# Legacy API compatibility methods
|
||||
def add_macro_legacy(
|
||||
self,
|
||||
name: str,
|
||||
macro_type: str,
|
||||
command: str,
|
||||
category: str = "",
|
||||
modifiers: Optional[dict] = None,
|
||||
image_path: str = ""
|
||||
) -> str:
|
||||
"""Add macro using legacy format (auto-converts to sequence)."""
|
||||
if modifiers is None:
|
||||
modifiers = {"ctrl": False, "alt": False, "shift": False, "enter": False}
|
||||
|
||||
# Build command sequence
|
||||
commands = []
|
||||
held_keys = []
|
||||
for mod in ["ctrl", "alt", "shift"]:
|
||||
if modifiers.get(mod):
|
||||
held_keys.append(mod)
|
||||
|
||||
if macro_type == "text":
|
||||
if held_keys:
|
||||
commands.append({"type": "hotkey", "keys": held_keys + [command]})
|
||||
else:
|
||||
commands.append({"type": "text", "value": command})
|
||||
|
||||
if modifiers.get("enter"):
|
||||
commands.append({"type": "key", "value": "enter"})
|
||||
elif macro_type == "app":
|
||||
commands.append({"type": "app", "command": command})
|
||||
|
||||
return self.add_macro(name, commands, category, image_path)
|
||||
|
||||
def get_macro(self, macro_id: str) -> Optional[dict]:
|
||||
"""Get a macro by ID."""
|
||||
return self.macros.get(macro_id)
|
||||
|
||||
def get_all_macros(self) -> dict:
|
||||
"""Get all macros."""
|
||||
return self.macros.copy()
|
||||
|
||||
Reference in New Issue
Block a user