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:
2026-01-03 16:57:14 -08:00
parent ded281cc64
commit 5888aeb603
29 changed files with 3436 additions and 1604 deletions

View File

@@ -1,157 +1,159 @@
name: Build and Release
on:
push:
branches:
- main
jobs:
create-release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Get version
id: get_version
run: |
VERSION=$(cat version.txt)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ env.VERSION }}
name: Release v${{ env.VERSION }}
draft: false
prerelease: false
build-windows:
needs: [create-release]
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r requirements.txt
- name: Build executable
run: |
pyinstaller macropad.spec
- name: Upload Windows artifact
uses: actions/upload-artifact@v3
with:
name: macropad-windows
path: dist/macropad.exe
build-linux:
needs: [create-release]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y python3-tk python3-dev
# Pystray requirements
sudo apt-get install -y libgtk-3-dev python3-gi python3-gi-cairo gir1.2-gtk-3.0
# Direct dependencies for system tray functionality
sudo apt-get install -y gir1.2-appindicator3-0.1
# Additional libraries
sudo apt-get install -y libcairo2-dev libgirepository1.0-dev
# For QR code display
sudo apt-get install -y python3-pil.imagetk
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r requirements.txt
- name: Build executable
run: |
pyinstaller macropad_linux.spec
- name: Upload Linux artifact
uses: actions/upload-artifact@v3
with:
name: macropad-linux
path: dist/macropad
# MacOS build is temporarily disabled
# Uncomment this section when macOS build environment becomes available
# =============================================================================
# WORKFLOW DISABLED - Pending testing of v0.9.0 modernization
# =============================================================================
# This workflow is temporarily disabled while testing the new:
# - PySide6 GUI (replacing Tkinter)
# - FastAPI web server (replacing Flask)
# - pyproject.toml build system (replacing requirements.txt)
# - PWA web interface
#
# build-macos:
# needs: [create-release]
# runs-on: macos-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
#
# - name: Set up Python
# uses: actions/setup-python@v4
# with:
# python-version: '3.11'
#
# - name: Install dependencies
# run: |
# python -m pip install --upgrade pip
# pip install pyinstaller
# pip install -r requirements.txt
#
# - name: Build executable
# run: |
# pyinstaller macropad_macos.spec
#
# - name: Upload macOS artifact
# uses: actions/upload-artifact@v3
# with:
# name: macropad-macos
# path: dist/macropad.app
# Uncomment the workflow below once builds are verified locally.
# =============================================================================
attach-to-release:
needs: [create-release, build-windows, build-linux]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Get version
id: get_version
run: |
VERSION=$(cat version.txt)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Download all artifacts
uses: actions/download-artifact@v3
- name: Attach executables to release
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ env.VERSION }}
files: |
macropad-windows/macropad.exe
macropad-linux/macropad
# macropad-macos/macropad.app/**/*
# name: Build and Release
#
# on:
# push:
# branches:
# - main
#
# jobs:
# create-release:
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
#
# - name: Get version
# id: get_version
# run: |
# VERSION=$(cat version.txt)
# echo "VERSION=$VERSION" >> $GITHUB_ENV
#
# - name: Create Release
# id: create_release
# uses: softprops/action-gh-release@v1
# with:
# tag_name: v${{ env.VERSION }}
# name: Release v${{ env.VERSION }}
# draft: false
# prerelease: false
#
# build-windows:
# needs: [create-release]
# runs-on: windows-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
#
# - name: Set up Python
# uses: actions/setup-python@v4
# with:
# python-version: '3.11'
#
# - name: Install dependencies
# run: |
# python -m pip install --upgrade pip
# pip install pyinstaller
# pip install -e .
#
# - name: Build executable
# run: |
# pyinstaller macropad.spec
#
# - name: Upload Windows artifact
# uses: actions/upload-artifact@v3
# with:
# name: macropad-windows
# path: dist/macropad.exe
#
# build-linux:
# needs: [create-release]
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
#
# - name: Set up Python
# uses: actions/setup-python@v4
# with:
# python-version: '3.11'
#
# - name: Install system dependencies
# run: |
# sudo apt-get update
# # PySide6 requirements
# sudo apt-get install -y libxcb-xinerama0 libxkbcommon-x11-0 libegl1
# # System tray requirements
# sudo apt-get install -y libgtk-3-dev python3-gi python3-gi-cairo gir1.2-gtk-3.0
# sudo apt-get install -y gir1.2-appindicator3-0.1
# sudo apt-get install -y libcairo2-dev libgirepository1.0-dev
#
# - name: Install dependencies
# run: |
# python -m pip install --upgrade pip
# pip install pyinstaller
# pip install -e .
#
# - name: Build executable
# run: |
# pyinstaller macropad_linux.spec
#
# - name: Upload Linux artifact
# uses: actions/upload-artifact@v3
# with:
# name: macropad-linux
# path: dist/macropad
#
# # MacOS build - requires macos runner
# # build-macos:
# # needs: [create-release]
# # runs-on: macos-latest
# # steps:
# # - name: Checkout code
# # uses: actions/checkout@v3
# #
# # - name: Set up Python
# # uses: actions/setup-python@v4
# # with:
# # python-version: '3.11'
# #
# # - name: Install dependencies
# # run: |
# # python -m pip install --upgrade pip
# # pip install pyinstaller
# # pip install -e .
# #
# # - name: Build executable
# # run: |
# # pyinstaller macropad_macos.spec
# #
# # - name: Upload macOS artifact
# # uses: actions/upload-artifact@v3
# # with:
# # name: macropad-macos
# # path: dist/macropad.app
#
# attach-to-release:
# needs: [create-release, build-windows, build-linux]
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
#
# - name: Get version
# id: get_version
# run: |
# VERSION=$(cat version.txt)
# echo "VERSION=$VERSION" >> $GITHUB_ENV
#
# - name: Download all artifacts
# uses: actions/download-artifact@v3
#
# - name: Attach executables to release
# uses: softprops/action-gh-release@v1
# with:
# tag_name: v${{ env.VERSION }}
# files: |
# macropad-windows/macropad.exe
# macropad-linux/macropad

BIN
Macro Pad.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

301
README.md
View File

@@ -1,164 +1,261 @@
# MacroPad Server
A versatile MacroPad server application that lets you create, manage, and execute custom macros from both a local interface and remotely via a web interface.
A cross-platform macro management application with desktop and web interfaces. Create powerful command sequences with delays, key presses, and text input - accessible locally or remotely via a PWA-enabled web interface.
## Features
- **Text Macros**: Insert frequently used text snippets with a single click
- **Application Macros**: Launch applications or scripts directly
- **Key Modifiers**: Add Ctrl, Alt, Shift modifiers and Enter keypress to your text macros
### Macro Capabilities
- **Command Sequences**: Build multi-step macros with:
- **Text**: Type text strings
- **Keys**: Press individual keys (Enter, Tab, Escape, etc.)
- **Hotkeys**: Key combinations (Ctrl+C, Alt+Tab, etc.)
- **Wait**: Insert delays between commands (in milliseconds)
- **App**: Launch applications or scripts
- **Custom Images**: Assign images to macros for easy identification
- **Category Management**: Organize macros into custom tabs for better organization
- **Web Interface**: Access and trigger your macros from other devices on your network
- **System Tray Integration**: Minimize to tray when minimized, exit when closed
- **QR Code Generation**: Quickly connect mobile devices to the web interface
- **Sorting Options**: Sort macros by name, type, or recent usage
- **Persistent Storage**: Macros are automatically saved for future sessions
- **Dark Theme**: Modern dark interface for comfortable use
- **Modular Architecture**: Clean separation of concerns with dedicated modules
- **Category Management**: Organize macros into custom tabs
### Interfaces
- **Desktop GUI**: Modern PySide6/Qt interface with visual command builder
- **Web Interface**: PWA-enabled for installation on any device
- **System Tray**: Minimize to tray, always accessible
### Additional Features
- **QR Code Generation**: Quickly connect mobile devices
- **Real-time Sync**: WebSocket updates across all connected devices
- **Offline Support**: PWA caches for offline macro viewing
- **Dark Theme**: Modern dark interface throughout
- **Auto-Migration**: Existing macros automatically upgraded to new format
## Requirements
- Python 3.11+
- Required Python packages (install via requirements.txt):
- tkinter
- flask
- pyautogui
- pystray
- Pillow (PIL)
- waitress
- netifaces
- qrcode
- Dependencies managed via `pyproject.toml`
### Core Dependencies
- PySide6 (Desktop GUI)
- FastAPI + Uvicorn (Web server)
- PyAutoGUI (Keyboard automation)
- Pillow (Image processing)
- pystray (System tray)
- netifaces (Network detection)
- qrcode (QR code generation)
## Installation
### Method 1: From Source
### Method 1: Using uv (Recommended)
1. Clone or download this repository
2. Install the required dependencies:
```bash
pip install -r requirements.txt
# Install uv if not already installed
curl -LsSf https://astral.sh/uv/install.sh | sh
# Clone and install
git clone https://repo.anhonesthost.net/MacroPad/MP-Server.git
cd MP-Server
uv sync
```
### Method 2: Pre-built Executables
### Method 2: Using pip
```bash
git clone https://repo.anhonesthost.net/MacroPad/MP-Server.git
cd MP-Server
pip install -e .
```
### Method 3: Pre-built Executables
1. Go to the [Releases](https://repo.anhonesthost.net/MacroPad/MP-Server/releases) page
2. Download the appropriate version for your operating system:
2. Download for your operating system:
- Windows: `macropad.exe`
- Linux: `macropad`
- macOS: `macropad.app`
- macOS: `MacroPad Server.app`
3. Run the downloaded file
> [!IMPORTANT]
> The executables are unsigned and may trigger security warnings. You may need to click "More info" and "Run anyway" in Windows SmartScreen, adjust permissions on Linux (`chmod +x macropad`), or override Gatekeeper on macOS.
> Executables are unsigned and may trigger security warnings. You may need to:
> - Windows: Click "More info" → "Run anyway" in SmartScreen
> - Linux: Run `chmod +x macropad` before executing
> - macOS: Right-click → Open, or adjust Gatekeeper settings
## Usage
### Main Interface
### Running the Application
When launched, MacroPad displays your existing macros with options to:
```bash
# If installed with uv
uv run python main.py
- **Add New Macro**: Create text snippets or application shortcuts
- **Edit Macro**: Modify existing macros
- **Delete Macro**: Remove unwanted macros
- **Sort Options**: Sort the Macros by type, name, and recent usage
- **Manage Tabs**: Assign categories to macros for better organization
- **Start Web Server**: Starts the web server to serve the MacroPad web interface.
# If installed with pip
python main.py
```
### Creating a Macro
1. Click the "Add Macro" button
2. Fill in the details:
- **Name**: A descriptive name for your macro
- **Category**: Assign a category to associate with a tab
- **Type**: Choose between Text or Application
- **Command/Text**: The text to insert or application command to run
- **Modifiers**: Select any combination of Ctrl, Alt, Shift, and Enter
- **Image**: Optionally add an image for visual identification
3. Click "Save" to create your macro
1. Click **+ Add Macro** in the toolbar
2. Enter a **Name** and optional **Category**
3. Build your command sequence using the buttons:
- **+ Text**: Add text to type
- **+ Key**: Add a key press (enter, tab, escape, etc.)
- **+ Hotkey**: Add a key combination (ctrl+c, alt+tab)
- **+ Wait**: Add a delay in milliseconds
- **+ App**: Add an application to launch
4. Reorder commands with **Up/Down** buttons
5. Click **Save**
### Remote Access
### Example: Login Macro
The application runs a web server enabling remote access:
A macro that types a username, waits, presses Tab, types a password, and presses Enter:
1. Note your computer's local IP address (shown in the application header)
2. From another device on the same network, open a web browser
3. Navigate to `http://<your-ip-address>:40000`
4. Click on any macro to execute it on your main computer
```
[TEXT] myusername
[WAIT] 200ms
[KEY] tab
[TEXT] mypassword
[KEY] enter
```
### Web Interface (PWA)
1. Start the application (web server starts automatically on port 40000)
2. Note the URL shown in the toolbar (e.g., `http://192.168.1.100:40000`)
3. Open this URL on any device on your network
4. **Install as PWA**:
- Mobile: Tap browser menu → "Add to Home Screen"
- Desktop: Click install icon in address bar
The web interface provides full macro management:
- View and execute macros
- Create and edit macros with command builder
- Organize into categories
- Real-time sync across devices
### System Tray
When minimized to the system tray:
- Right-click the tray icon to show options
- Select "Show" to restore the window
- Select "Exit" to close the application
- Minimize window → App continues in tray
- Right-click tray icon:
- **Show**: Restore window
- **Quit**: Exit application
## Command Types Reference
| Type | Description | Parameters |
|------|-------------|------------|
| `text` | Types a text string | `value`: The text to type |
| `key` | Presses a single key | `value`: Key name (enter, tab, escape, f1-f12, etc.) |
| `hotkey` | Presses key combination | `keys`: List of keys (e.g., ["ctrl", "c"]) |
| `wait` | Delays execution | `ms`: Milliseconds to wait |
| `app` | Launches application | `command`: Shell command to execute |
## Example Application Commands
### Windows Examples
#### Steam Applications
```
### Windows
```bash
# Steam game
"C:\Program Files (x86)\Steam\steam.exe" steam://rungameid/2767030
```
#### Chrome to a website
```
"C:\Program Files\Google\Chrome\Application\chrome.exe" http://twitch.tv/shadowdao
```
# Chrome to website
"C:\Program Files\Google\Chrome\Application\chrome.exe" https://example.com
#### Run Notepad
```
notepad.exe
```
#### Open File Explorer to a specific location
```
# Open folder
explorer.exe "C:\Users\YourUsername\Documents"
```
### Linux Examples
#### Opening Firefox
```
### Linux
```bash
# Firefox
firefox https://example.com
```
#### Opening Steam
```
# Steam game
steam steam://rungameid/2767030
```
#### Launch Terminal
```
# Terminal
gnome-terminal
```
#### Open File Manager
```
nautilus ~/Documents
```
### macOS Examples
#### Opening Safari
```
### macOS
```bash
# Safari
open -a Safari https://example.com
```
#### Opening Terminal
```
open -a Terminal
```
# VS Code
open -a "Visual Studio Code"
#### Open Finder to a specific location
```
# Folder
open ~/Documents
```
#### Launch Applications
```
open -a "Visual Studio Code"
## Building Executables
Build platform-specific executables using PyInstaller:
```bash
# Install PyInstaller
pip install pyinstaller
# Windows (run on Windows)
pyinstaller macropad.spec
# Linux (run on Linux)
pyinstaller macropad_linux.spec
# macOS (run on macOS)
pyinstaller macropad_macos.spec
```
#### Special Thanks to CatArgent_ on Twitch for proof reading my stuff and providing valuable feedback.
> [!NOTE]
> PyInstaller cannot cross-compile. You must build on the target platform.
## Project Structure
```
MP-Server/
├── main.py # Application entry point
├── config.py # Configuration constants
├── macro_manager.py # Macro storage and execution
├── web_server.py # FastAPI web server
├── pyproject.toml # Dependencies and build config
├── gui/ # PySide6 desktop interface
│ ├── main_window.py
│ ├── macro_editor.py
│ └── command_builder.py
├── web/ # PWA web interface
│ ├── index.html
│ ├── manifest.json
│ ├── service-worker.js
│ ├── css/styles.css
│ ├── js/app.js
│ └── icons/
└── macros.json # Macro storage (auto-created)
```
## Migrating from v0.8.x
Existing macros are automatically migrated on first run. The old format:
```json
{
"type": "text",
"command": "Hello",
"modifiers": {"enter": true}
}
```
Becomes the new command sequence format:
```json
{
"type": "sequence",
"commands": [
{"type": "text", "value": "Hello"},
{"type": "key", "value": "enter"}
]
}
```
## License
MIT License
## Acknowledgments
Special thanks to CatArgent_ on Twitch for proofreading and providing valuable feedback.

View File

@@ -1,6 +1,6 @@
# Configuration and constants for MacroPad Server
VERSION = "0.8.5 Beta"
VERSION = "0.9.0"
DEFAULT_PORT = 40000
# UI Theme colors

BIN
dist/macropad.exe vendored

Binary file not shown.

7
gui/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
# MacroPad Server GUI Module
# PySide6-based desktop interface
from .main_window import MainWindow
from .macro_editor import MacroEditorDialog, CommandBuilder
__all__ = ['MainWindow', 'MacroEditorDialog', 'CommandBuilder']

5
gui/command_builder.py Normal file
View File

@@ -0,0 +1,5 @@
# Command builder widget (re-exported from macro_editor for convenience)
from .macro_editor import CommandBuilder, CommandItem
__all__ = ['CommandBuilder', 'CommandItem']

591
gui/macro_editor.py Normal file
View File

@@ -0,0 +1,591 @@
# Macro editor dialog with command builder (PySide6)
import os
from typing import Optional, List, Dict
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem,
QComboBox, QSpinBox, QMessageBox, QFileDialog, QWidget,
QGroupBox, QScrollArea
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QPixmap, QIcon
from config import THEME, IMAGE_EXTENSIONS
class CommandItem(QWidget):
"""Widget representing a single command in the list."""
delete_clicked = Signal()
move_up_clicked = Signal()
move_down_clicked = Signal()
edit_clicked = Signal()
def __init__(self, command: dict, parent=None):
super().__init__(parent)
self.command = command
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(8)
# Type label
type_label = QLabel(command.get("type", "").upper())
type_label.setFixedWidth(60)
type_label.setStyleSheet(f"""
background-color: {THEME['accent_color']};
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: bold;
""")
type_label.setAlignment(Qt.AlignCenter)
layout.addWidget(type_label)
# Value label
value_label = QLabel(self._get_display_value())
value_label.setStyleSheet(f"color: {THEME['fg_color']}; font-family: monospace;")
layout.addWidget(value_label, 1)
# Action buttons
btn_style = f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 4px 8px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
"""
edit_btn = QPushButton("Edit")
edit_btn.setStyleSheet(btn_style)
edit_btn.clicked.connect(self.edit_clicked.emit)
layout.addWidget(edit_btn)
up_btn = QPushButton("^")
up_btn.setStyleSheet(btn_style)
up_btn.setFixedWidth(30)
up_btn.clicked.connect(self.move_up_clicked.emit)
layout.addWidget(up_btn)
down_btn = QPushButton("v")
down_btn.setStyleSheet(btn_style)
down_btn.setFixedWidth(30)
down_btn.clicked.connect(self.move_down_clicked.emit)
layout.addWidget(down_btn)
del_btn = QPushButton("X")
del_btn.setStyleSheet(f"""
QPushButton {{
background-color: #dc3545;
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: #c82333;
}}
""")
del_btn.setFixedWidth(30)
del_btn.clicked.connect(self.delete_clicked.emit)
layout.addWidget(del_btn)
def _get_display_value(self) -> str:
"""Get display text for the command."""
cmd_type = self.command.get("type", "")
if cmd_type == "text":
return self.command.get("value", "")[:50]
elif cmd_type == "key":
return self.command.get("value", "")
elif cmd_type == "hotkey":
return " + ".join(self.command.get("keys", []))
elif cmd_type == "wait":
return f"{self.command.get('ms', 0)}ms"
elif cmd_type == "app":
return self.command.get("command", "")[:50]
return ""
class CommandBuilder(QWidget):
"""Widget for building command sequences."""
commands_changed = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.commands: List[dict] = []
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Command list
self.list_widget = QListWidget()
self.list_widget.setStyleSheet(f"""
QListWidget {{
background-color: {THEME['bg_color']};
border: 1px solid {THEME['button_bg']};
border-radius: 4px;
}}
QListWidget::item {{
padding: 4px;
}}
""")
self.list_widget.setMinimumHeight(150)
layout.addWidget(self.list_widget)
# Add command buttons
btn_layout = QHBoxLayout()
for cmd_type, label in [
("text", "+ Text"),
("key", "+ Key"),
("hotkey", "+ Hotkey"),
("wait", "+ Wait"),
("app", "+ App")
]:
btn = QPushButton(label)
btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 8px 12px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['accent_color']};
}}
""")
btn.clicked.connect(lambda checked, t=cmd_type: self.add_command(t))
btn_layout.addWidget(btn)
layout.addLayout(btn_layout)
def set_commands(self, commands: List[dict]):
"""Set the command list."""
self.commands = list(commands)
self.refresh()
def get_commands(self) -> List[dict]:
"""Get the command list."""
return list(self.commands)
def refresh(self):
"""Refresh the command list display."""
self.list_widget.clear()
for i, cmd in enumerate(self.commands):
item = QListWidgetItem(self.list_widget)
widget = CommandItem(cmd)
widget.delete_clicked.connect(lambda idx=i: self.remove_command(idx))
widget.move_up_clicked.connect(lambda idx=i: self.move_command(idx, -1))
widget.move_down_clicked.connect(lambda idx=i: self.move_command(idx, 1))
widget.edit_clicked.connect(lambda idx=i: self.edit_command(idx))
item.setSizeHint(widget.sizeHint())
self.list_widget.addItem(item)
self.list_widget.setItemWidget(item, widget)
def add_command(self, cmd_type: str):
"""Add a new command."""
command = {"type": cmd_type}
if cmd_type == "text":
from PySide6.QtWidgets import QInputDialog
text, ok = QInputDialog.getText(self, "Text Command", "Enter text to type:")
if not ok or not text:
return
command["value"] = text
elif cmd_type == "key":
from PySide6.QtWidgets import QInputDialog
keys = ["enter", "tab", "escape", "space", "backspace", "delete",
"up", "down", "left", "right", "home", "end", "pageup", "pagedown",
"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12"]
key, ok = QInputDialog.getItem(self, "Key Command", "Select key:", keys, 0, True)
if not ok or not key:
return
command["value"] = key.lower()
elif cmd_type == "hotkey":
from PySide6.QtWidgets import QInputDialog
text, ok = QInputDialog.getText(
self, "Hotkey Command",
"Enter key combination (comma separated, e.g., ctrl,c):"
)
if not ok or not text:
return
command["keys"] = [k.strip().lower() for k in text.split(",")]
elif cmd_type == "wait":
from PySide6.QtWidgets import QInputDialog
ms, ok = QInputDialog.getInt(
self, "Wait Command",
"Enter delay in milliseconds:",
500, 0, 60000, 100
)
if not ok:
return
command["ms"] = ms
elif cmd_type == "app":
from PySide6.QtWidgets import QInputDialog
cmd, ok = QInputDialog.getText(self, "App Command", "Enter application command:")
if not ok or not cmd:
return
command["command"] = cmd
self.commands.append(command)
self.refresh()
self.commands_changed.emit()
def remove_command(self, index: int):
"""Remove a command at index."""
if 0 <= index < len(self.commands):
del self.commands[index]
self.refresh()
self.commands_changed.emit()
def move_command(self, index: int, direction: int):
"""Move a command up or down."""
new_index = index + direction
if 0 <= new_index < len(self.commands):
self.commands[index], self.commands[new_index] = \
self.commands[new_index], self.commands[index]
self.refresh()
self.commands_changed.emit()
def edit_command(self, index: int):
"""Edit a command at index."""
if not (0 <= index < len(self.commands)):
return
cmd = self.commands[index]
cmd_type = cmd.get("type", "")
from PySide6.QtWidgets import QInputDialog
if cmd_type == "text":
text, ok = QInputDialog.getText(
self, "Edit Text", "Enter text:",
text=cmd.get("value", "")
)
if ok and text:
cmd["value"] = text
elif cmd_type == "key":
keys = ["enter", "tab", "escape", "space", "backspace", "delete",
"up", "down", "left", "right", "home", "end", "pageup", "pagedown",
"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12"]
current = keys.index(cmd.get("value", "enter")) if cmd.get("value") in keys else 0
key, ok = QInputDialog.getItem(self, "Edit Key", "Select key:", keys, current, True)
if ok and key:
cmd["value"] = key.lower()
elif cmd_type == "hotkey":
text, ok = QInputDialog.getText(
self, "Edit Hotkey", "Enter key combination:",
text=",".join(cmd.get("keys", []))
)
if ok and text:
cmd["keys"] = [k.strip().lower() for k in text.split(",")]
elif cmd_type == "wait":
ms, ok = QInputDialog.getInt(
self, "Edit Wait", "Enter delay in milliseconds:",
cmd.get("ms", 500), 0, 60000, 100
)
if ok:
cmd["ms"] = ms
elif cmd_type == "app":
text, ok = QInputDialog.getText(
self, "Edit App", "Enter application command:",
text=cmd.get("command", "")
)
if ok and text:
cmd["command"] = text
self.refresh()
self.commands_changed.emit()
class MacroEditorDialog(QDialog):
"""Dialog for creating/editing macros."""
def __init__(self, macro_manager, macro_id: Optional[str] = None, parent=None):
super().__init__(parent)
self.macro_manager = macro_manager
self.macro_id = macro_id
self.image_path = ""
self.setWindowTitle("Edit Macro" if macro_id else "Add Macro")
self.setMinimumSize(500, 500)
self.setStyleSheet(f"""
QDialog {{
background-color: {THEME['highlight_color']};
}}
QLabel {{
color: {THEME['fg_color']};
}}
QLineEdit {{
background-color: {THEME['bg_color']};
border: 1px solid {THEME['button_bg']};
border-radius: 4px;
padding: 8px;
color: {THEME['fg_color']};
}}
QLineEdit:focus {{
border-color: {THEME['accent_color']};
}}
""")
self.setup_ui()
# Load existing macro data if editing
if macro_id:
self.load_macro()
def setup_ui(self):
"""Setup the dialog UI."""
layout = QVBoxLayout(self)
layout.setSpacing(16)
# Scroll area for content
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setStyleSheet("border: none;")
content = QWidget()
content_layout = QVBoxLayout(content)
content_layout.setSpacing(12)
# Name field
name_group = QGroupBox("Macro Name")
name_group.setStyleSheet(f"""
QGroupBox {{
color: {THEME['fg_color']};
font-weight: bold;
border: 1px solid {THEME['button_bg']};
border-radius: 4px;
margin-top: 8px;
padding-top: 8px;
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 10px;
}}
""")
name_layout = QVBoxLayout(name_group)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Enter macro name")
name_layout.addWidget(self.name_input)
content_layout.addWidget(name_group)
# Category field
category_group = QGroupBox("Category (optional)")
category_group.setStyleSheet(name_group.styleSheet())
category_layout = QVBoxLayout(category_group)
self.category_input = QLineEdit()
self.category_input.setPlaceholderText("Enter category")
category_layout.addWidget(self.category_input)
content_layout.addWidget(category_group)
# Command builder
commands_group = QGroupBox("Commands")
commands_group.setStyleSheet(name_group.styleSheet())
commands_layout = QVBoxLayout(commands_group)
self.command_builder = CommandBuilder()
commands_layout.addWidget(self.command_builder)
content_layout.addWidget(commands_group)
# Image selection
image_group = QGroupBox("Image (optional)")
image_group.setStyleSheet(name_group.styleSheet())
image_layout = QHBoxLayout(image_group)
self.image_preview = QLabel()
self.image_preview.setFixedSize(64, 64)
self.image_preview.setStyleSheet(f"""
background-color: {THEME['bg_color']};
border-radius: 4px;
""")
self.image_preview.setAlignment(Qt.AlignCenter)
image_layout.addWidget(self.image_preview)
image_btn_layout = QVBoxLayout()
select_btn = QPushButton("Select Image")
select_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 8px 16px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['accent_color']};
}}
""")
select_btn.clicked.connect(self.select_image)
image_btn_layout.addWidget(select_btn)
clear_btn = QPushButton("Clear Image")
clear_btn.setStyleSheet(select_btn.styleSheet())
clear_btn.clicked.connect(self.clear_image)
image_btn_layout.addWidget(clear_btn)
image_layout.addLayout(image_btn_layout)
image_layout.addStretch()
content_layout.addWidget(image_group)
content_layout.addStretch()
scroll.setWidget(content)
layout.addWidget(scroll)
# Dialog buttons
btn_layout = QHBoxLayout()
if self.macro_id:
delete_btn = QPushButton("Delete")
delete_btn.setStyleSheet(f"""
QPushButton {{
background-color: #dc3545;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-weight: bold;
}}
QPushButton:hover {{
background-color: #c82333;
}}
""")
delete_btn.clicked.connect(self.delete_macro)
btn_layout.addWidget(delete_btn)
btn_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 10px 20px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
""")
cancel_btn.clicked.connect(self.reject)
btn_layout.addWidget(cancel_btn)
save_btn = QPushButton("Save")
save_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['accent_color']};
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-weight: bold;
}}
QPushButton:hover {{
background-color: #0096ff;
}}
""")
save_btn.clicked.connect(self.save_macro)
btn_layout.addWidget(save_btn)
layout.addLayout(btn_layout)
def load_macro(self):
"""Load existing macro data into the form."""
macro = self.macro_manager.get_macro(self.macro_id)
if not macro:
return
self.name_input.setText(macro.get("name", ""))
self.category_input.setText(macro.get("category", ""))
self.command_builder.set_commands(macro.get("commands", []))
if macro.get("image_path"):
self.image_path = macro["image_path"]
pixmap = QPixmap(self.image_path)
if not pixmap.isNull():
self.image_preview.setPixmap(
pixmap.scaled(64, 64, Qt.KeepAspectRatio, Qt.SmoothTransformation)
)
def select_image(self):
"""Open file dialog to select an image."""
ext_filter = "Images (" + " ".join(f"*{ext}" for ext in IMAGE_EXTENSIONS) + ")"
file_path, _ = QFileDialog.getOpenFileName(
self, "Select Image", "", ext_filter
)
if file_path:
self.image_path = file_path
pixmap = QPixmap(file_path)
self.image_preview.setPixmap(
pixmap.scaled(64, 64, Qt.KeepAspectRatio, Qt.SmoothTransformation)
)
def clear_image(self):
"""Clear the selected image."""
self.image_path = ""
self.image_preview.clear()
def save_macro(self):
"""Save the macro."""
name = self.name_input.text().strip()
if not name:
QMessageBox.warning(self, "Error", "Please enter a macro name")
return
commands = self.command_builder.get_commands()
if not commands:
QMessageBox.warning(self, "Error", "Please add at least one command")
return
category = self.category_input.text().strip()
if self.macro_id:
# Update existing macro
self.macro_manager.update_macro(
self.macro_id,
name=name,
commands=commands,
category=category,
image_path=self.image_path if self.image_path else None
)
else:
# Create new macro
self.macro_manager.add_macro(
name=name,
commands=commands,
category=category,
image_path=self.image_path
)
self.accept()
def delete_macro(self):
"""Delete the current macro."""
reply = QMessageBox.question(
self, "Delete Macro",
"Are you sure you want to delete this macro?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.macro_manager.delete_macro(self.macro_id)
self.accept()

509
gui/main_window.py Normal file
View File

@@ -0,0 +1,509 @@
# Main window for MacroPad Server (PySide6)
import os
import sys
import threading
from typing import Optional
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QTabWidget, QGridLayout,
QScrollArea, QFrame, QMenu, QMenuBar, QStatusBar,
QMessageBox, QApplication, QSystemTrayIcon
)
from PySide6.QtCore import Qt, Signal, QTimer, QSize
from PySide6.QtGui import QIcon, QPixmap, QAction, QFont
from config import VERSION, THEME, DEFAULT_PORT
from macro_manager import MacroManager
from web_server import WebServer
class MacroButton(QPushButton):
"""Custom button widget for displaying a macro."""
def __init__(self, macro_id: str, macro: dict, parent=None):
super().__init__(parent)
self.macro_id = macro_id
self.macro = macro
self.setFixedSize(120, 100)
self.setCursor(Qt.PointingHandCursor)
self.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
border-radius: 8px;
padding: 8px;
text-align: center;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
QPushButton:pressed {{
background-color: {THEME['accent_color']};
}}
""")
# Layout
layout = QVBoxLayout(self)
layout.setSpacing(4)
layout.setContentsMargins(4, 4, 4, 4)
# Image or placeholder
image_label = QLabel()
image_label.setFixedSize(48, 48)
image_label.setAlignment(Qt.AlignCenter)
if macro.get("image_path"):
pixmap = QPixmap(macro["image_path"])
if not pixmap.isNull():
pixmap = pixmap.scaled(48, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation)
image_label.setPixmap(pixmap)
else:
self._set_placeholder(image_label, macro["name"])
else:
self._set_placeholder(image_label, macro["name"])
layout.addWidget(image_label, alignment=Qt.AlignCenter)
# Name label
name_label = QLabel(macro["name"])
name_label.setAlignment(Qt.AlignCenter)
name_label.setWordWrap(True)
name_label.setStyleSheet(f"color: {THEME['fg_color']}; font-size: 11px;")
layout.addWidget(name_label)
def _set_placeholder(self, label: QLabel, name: str):
"""Set a placeholder with the first letter of the name."""
label.setStyleSheet(f"""
background-color: {THEME['highlight_color']};
border-radius: 8px;
font-size: 20px;
font-weight: bold;
color: {THEME['fg_color']};
""")
label.setText(name[0].upper() if name else "?")
def contextMenuEvent(self, event):
"""Show context menu on right-click."""
menu = QMenu(self)
edit_action = menu.addAction("Edit")
delete_action = menu.addAction("Delete")
action = menu.exec_(event.globalPos())
if action == edit_action:
self.parent().parent().parent().parent().edit_macro(self.macro_id)
elif action == delete_action:
self.parent().parent().parent().parent().delete_macro(self.macro_id)
class MainWindow(QMainWindow):
"""Main application window."""
macros_changed = Signal()
def __init__(self, app_dir: str):
super().__init__()
self.app_dir = app_dir
self.current_tab = "All"
self.sort_by = "name"
# Initialize macro manager
data_file = os.path.join(app_dir, "macros.json")
images_dir = os.path.join(app_dir, "macro_images")
os.makedirs(images_dir, exist_ok=True)
self.macro_manager = MacroManager(data_file, images_dir, app_dir)
# Initialize web server
self.web_server = WebServer(self.macro_manager, app_dir, DEFAULT_PORT)
self.server_thread = None
# Setup UI
self.setup_ui()
self.setup_menu()
self.setup_tray()
# Start web server
self.start_server()
# Connect signals
self.macros_changed.connect(self.refresh_macros)
# Load initial data
self.refresh_tabs()
self.refresh_macros()
def setup_ui(self):
"""Setup the main UI components."""
self.setWindowTitle(f"MacroPad Server v{VERSION}")
self.setMinimumSize(600, 400)
self.setStyleSheet(f"background-color: {THEME['bg_color']};")
# Central widget
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
# Toolbar
toolbar = QWidget()
toolbar.setStyleSheet(f"background-color: {THEME['highlight_color']};")
toolbar_layout = QHBoxLayout(toolbar)
toolbar_layout.setContentsMargins(10, 10, 10, 10)
add_btn = QPushButton("+ Add Macro")
add_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['accent_color']};
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-weight: bold;
}}
QPushButton:hover {{
background-color: #0096ff;
}}
""")
add_btn.clicked.connect(self.add_macro)
toolbar_layout.addWidget(add_btn)
toolbar_layout.addStretch()
# IP address label
self.ip_label = QLabel()
self.ip_label.setStyleSheet(f"color: {THEME['fg_color']};")
self.update_ip_label()
toolbar_layout.addWidget(self.ip_label)
qr_btn = QPushButton("QR Code")
qr_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['button_bg']};
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
""")
qr_btn.clicked.connect(self.show_qr_code)
toolbar_layout.addWidget(qr_btn)
layout.addWidget(toolbar)
# Tab widget
self.tab_widget = QTabWidget()
self.tab_widget.setStyleSheet(f"""
QTabWidget::pane {{
border: none;
background: {THEME['bg_color']};
}}
QTabBar::tab {{
background: {THEME['tab_bg']};
color: {THEME['fg_color']};
padding: 8px 16px;
margin-right: 2px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}}
QTabBar::tab:selected {{
background: {THEME['tab_selected']};
}}
QTabBar::tab:hover {{
background: {THEME['highlight_color']};
}}
""")
self.tab_widget.currentChanged.connect(self.on_tab_changed)
layout.addWidget(self.tab_widget)
# Status bar
self.status_bar = QStatusBar()
self.status_bar.setStyleSheet(f"""
background-color: {THEME['highlight_color']};
color: {THEME['fg_color']};
""")
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("Ready")
def setup_menu(self):
"""Setup the menu bar."""
menubar = self.menuBar()
menubar.setStyleSheet(f"""
QMenuBar {{
background-color: {THEME['highlight_color']};
color: {THEME['fg_color']};
}}
QMenuBar::item:selected {{
background-color: {THEME['accent_color']};
}}
QMenu {{
background-color: {THEME['highlight_color']};
color: {THEME['fg_color']};
}}
QMenu::item:selected {{
background-color: {THEME['accent_color']};
}}
""")
# File menu
file_menu = menubar.addMenu("File")
add_action = QAction("Add Macro", self)
add_action.setShortcut("Ctrl+N")
add_action.triggered.connect(self.add_macro)
file_menu.addAction(add_action)
file_menu.addSeparator()
quit_action = QAction("Quit", self)
quit_action.setShortcut("Ctrl+Q")
quit_action.triggered.connect(self.close)
file_menu.addAction(quit_action)
# View menu
view_menu = menubar.addMenu("View")
refresh_action = QAction("Refresh", self)
refresh_action.setShortcut("F5")
refresh_action.triggered.connect(self.refresh_all)
view_menu.addAction(refresh_action)
# Sort submenu
sort_menu = view_menu.addMenu("Sort By")
for sort_option in [("Name", "name"), ("Type", "type"), ("Recent", "recent")]:
action = QAction(sort_option[0], self)
action.triggered.connect(lambda checked, s=sort_option[1]: self.set_sort(s))
sort_menu.addAction(action)
# Help menu
help_menu = menubar.addMenu("Help")
about_action = QAction("About", self)
about_action.triggered.connect(self.show_about)
help_menu.addAction(about_action)
def setup_tray(self):
"""Setup system tray icon."""
self.tray_icon = QSystemTrayIcon(self)
# Load icon
icon_path = os.path.join(self.app_dir, "Macro Pad.png")
if os.path.exists(icon_path):
self.tray_icon.setIcon(QIcon(icon_path))
self.setWindowIcon(QIcon(icon_path))
else:
self.tray_icon.setIcon(self.style().standardIcon(self.style().SP_ComputerIcon))
# Tray menu
tray_menu = QMenu()
show_action = tray_menu.addAction("Show")
show_action.triggered.connect(self.show)
quit_action = tray_menu.addAction("Quit")
quit_action.triggered.connect(self.close)
self.tray_icon.setContextMenu(tray_menu)
self.tray_icon.activated.connect(self.on_tray_activated)
self.tray_icon.show()
def on_tray_activated(self, reason):
"""Handle tray icon activation."""
if reason == QSystemTrayIcon.DoubleClick:
self.show()
self.activateWindow()
def start_server(self):
"""Start the web server in a background thread."""
self.web_server.create_app()
def run():
self.web_server.run()
self.server_thread = threading.Thread(target=run, daemon=True)
self.server_thread.start()
self.status_bar.showMessage(f"Server running on port {DEFAULT_PORT}")
def update_ip_label(self):
"""Update the IP address label."""
try:
import netifaces
for iface in netifaces.interfaces():
addrs = netifaces.ifaddresses(iface)
if netifaces.AF_INET in addrs:
for addr in addrs[netifaces.AF_INET]:
ip = addr.get('addr', '')
if ip and not ip.startswith('127.'):
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}")
return
except Exception:
pass
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}")
def refresh_tabs(self):
"""Refresh the tab widget."""
self.tab_widget.blockSignals(True)
self.tab_widget.clear()
tabs = self.macro_manager.get_unique_tabs()
for tab_name in tabs:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setStyleSheet(f"background-color: {THEME['bg_color']}; border: none;")
container = QWidget()
container.setStyleSheet(f"background-color: {THEME['bg_color']};")
scroll.setWidget(container)
self.tab_widget.addTab(scroll, tab_name)
self.tab_widget.blockSignals(False)
def refresh_macros(self):
"""Refresh the macro grid."""
current_index = self.tab_widget.currentIndex()
if current_index < 0:
return
scroll = self.tab_widget.widget(current_index)
container = scroll.widget()
# Clear existing layout
if container.layout():
while container.layout().count():
item = container.layout().takeAt(0)
if item.widget():
item.widget().deleteLater()
else:
container.setLayout(QGridLayout())
layout = container.layout()
layout.setSpacing(10)
layout.setContentsMargins(10, 10, 10, 10)
layout.setAlignment(Qt.AlignTop | Qt.AlignLeft)
# Get macros for current tab
tab_name = self.tab_widget.tabText(current_index)
macro_list = self.macro_manager.get_sorted_macros(self.sort_by)
filtered = self.macro_manager.filter_macros_by_tab(macro_list, tab_name)
# Add macro buttons
cols = max(1, (self.width() - 40) // 130)
for i, (macro_id, macro) in enumerate(filtered):
btn = MacroButton(macro_id, macro)
btn.clicked.connect(lambda checked, mid=macro_id: self.execute_macro(mid))
layout.addWidget(btn, i // cols, i % cols)
def on_tab_changed(self, index):
"""Handle tab change."""
self.refresh_macros()
def execute_macro(self, macro_id: str):
"""Execute a macro."""
success = self.macro_manager.execute_macro(macro_id)
if success:
self.status_bar.showMessage("Macro executed", 2000)
else:
self.status_bar.showMessage("Macro execution failed", 2000)
def add_macro(self):
"""Open dialog to add a new macro."""
from .macro_editor import MacroEditorDialog
dialog = MacroEditorDialog(self.macro_manager, parent=self)
if dialog.exec_():
self.refresh_tabs()
self.refresh_macros()
def edit_macro(self, macro_id: str):
"""Open dialog to edit a macro."""
from .macro_editor import MacroEditorDialog
dialog = MacroEditorDialog(self.macro_manager, macro_id=macro_id, parent=self)
if dialog.exec_():
self.refresh_tabs()
self.refresh_macros()
def delete_macro(self, macro_id: str):
"""Delete a macro with confirmation."""
reply = QMessageBox.question(
self, "Delete Macro",
"Are you sure you want to delete this macro?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.macro_manager.delete_macro(macro_id)
self.refresh_tabs()
self.refresh_macros()
def set_sort(self, sort_by: str):
"""Set the sort order."""
self.sort_by = sort_by
self.refresh_macros()
def refresh_all(self):
"""Refresh tabs and macros."""
self.refresh_tabs()
self.refresh_macros()
def show_qr_code(self):
"""Show QR code dialog."""
try:
import qrcode
from PySide6.QtWidgets import QDialog, QVBoxLayout
from io import BytesIO
url = self.ip_label.text()
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Convert to QPixmap
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
pixmap = QPixmap()
pixmap.loadFromData(buffer.read())
# Show dialog
dialog = QDialog(self)
dialog.setWindowTitle("QR Code")
layout = QVBoxLayout(dialog)
label = QLabel()
label.setPixmap(pixmap.scaled(300, 300, Qt.KeepAspectRatio, Qt.SmoothTransformation))
layout.addWidget(label)
url_label = QLabel(url)
url_label.setStyleSheet(f"color: {THEME['fg_color']};")
url_label.setAlignment(Qt.AlignCenter)
layout.addWidget(url_label)
dialog.exec_()
except ImportError:
QMessageBox.warning(self, "Error", "QR code library not available")
def show_about(self):
"""Show about dialog."""
QMessageBox.about(
self, "About MacroPad Server",
f"MacroPad Server v{VERSION}\n\n"
"A cross-platform macro management application\n"
"with desktop and web interfaces.\n\n"
"PWA-enabled for mobile devices."
)
def closeEvent(self, event):
"""Handle window close."""
self.tray_icon.hide()
event.accept()
def resizeEvent(self, event):
"""Handle window resize."""
super().resizeEvent(event)
self.refresh_macros()

1
gui/widgets/__init__.py Normal file
View File

@@ -0,0 +1 @@
# MacroPad Server GUI Widgets

View File

@@ -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()

View File

@@ -1,12 +1,53 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec file for MacroPad Server (Windows)
import os
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
datas=[
('web', 'web'),
('Macro Pad.png', '.'),
],
hiddenimports=[
# PySide6
'PySide6.QtCore',
'PySide6.QtGui',
'PySide6.QtWidgets',
# FastAPI and web server
'fastapi',
'uvicorn',
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'starlette',
'starlette.routing',
'starlette.responses',
'starlette.staticfiles',
'starlette.websockets',
'anyio',
'anyio._backends._asyncio',
# Pydantic
'pydantic',
'pydantic.fields',
# Other dependencies
'qrcode',
'PIL',
'pyautogui',
'pystray',
'netifaces',
'websockets',
'multipart',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
@@ -14,6 +55,7 @@ a = Analysis(
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
@@ -35,4 +77,5 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='Macro Pad.png',
)

View File

@@ -1,4 +1,7 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec file for MacroPad Server (Linux)
import os
block_cipher = None
@@ -6,15 +9,54 @@ a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
datas=[
('web', 'web'),
('Macro Pad.png', '.'),
],
hiddenimports=[
# PySide6
'PySide6.QtCore',
'PySide6.QtGui',
'PySide6.QtWidgets',
'PySide6.QtDBus',
# FastAPI and web server
'fastapi',
'uvicorn',
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'starlette',
'starlette.routing',
'starlette.responses',
'starlette.staticfiles',
'starlette.websockets',
'anyio',
'anyio._backends._asyncio',
# Pydantic
'pydantic',
'pydantic.fields',
# Other dependencies
'qrcode',
'PIL._tkinter_finder',
'PIL',
'pyautogui',
'pystray',
'pystray._base',
'netifaces',
'websockets',
'multipart',
# Linux system tray
'gi',
'gi.repository.Gtk',
'gi.repository.AppIndicator3',
'gi.repository.GdkPixbuf',
# Packaging
'packaging.version',
'packaging.specifiers',
'packaging.requirements',
@@ -28,7 +70,9 @@ a = Analysis(
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
@@ -49,4 +93,4 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
)

View File

@@ -1,12 +1,53 @@
# -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec file for MacroPad Server (macOS)
import os
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
datas=[
('web', 'web'),
('Macro Pad.png', '.'),
],
hiddenimports=[
# PySide6
'PySide6.QtCore',
'PySide6.QtGui',
'PySide6.QtWidgets',
# FastAPI and web server
'fastapi',
'uvicorn',
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'starlette',
'starlette.routing',
'starlette.responses',
'starlette.staticfiles',
'starlette.websockets',
'anyio',
'anyio._backends._asyncio',
# Pydantic
'pydantic',
'pydantic.fields',
# Other dependencies
'qrcode',
'PIL',
'pyautogui',
'pystray',
'netifaces',
'websockets',
'multipart',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
@@ -14,6 +55,7 @@ a = Analysis(
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
@@ -39,7 +81,12 @@ exe = EXE(
app = BUNDLE(
exe,
name='macropad.app',
icon=None,
bundle_identifier=None,
)
name='MacroPad Server.app',
icon='Macro Pad.png',
bundle_identifier='com.macropad.server',
info_plist={
'CFBundleShortVersionString': '0.9.0',
'CFBundleName': 'MacroPad Server',
'NSHighResolutionCapable': True,
},
)

520
main.py
View File

@@ -1,502 +1,52 @@
# Main application file for MacroPad Server
# PySide6 version
import tkinter as tk
from tkinter import ttk, messagebox
import os
import sys
import threading
import socket
import qrcode
import webbrowser
import pystray
from PIL import Image, ImageTk, ImageDraw, ImageFont
from config import VERSION, DEFAULT_PORT, THEME
from macro_manager import MacroManager
from web_server import WebServer
from ui_components import MacroDialog, MacroSelector, TabManager
import multiprocessing
class MacroPadServer:
def __init__(self, root):
self.root = root
self.root.title("MacroPad Server")
self.root.geometry("800x600")
self.configure_styles()
# Set up directories
if getattr(sys, 'frozen', False):
base_dir = os.path.dirname(sys.executable)
else:
base_dir = os.path.dirname(os.path.abspath(__file__))
data_file = os.path.join(base_dir, "macros.json")
images_dir = os.path.join(base_dir, "macro_images")
os.makedirs(images_dir, exist_ok=True)
def get_app_dir():
"""Get the application directory."""
if getattr(sys, 'frozen', False):
# Running as compiled executable
return os.path.dirname(sys.executable)
else:
# Running as script
return os.path.dirname(os.path.abspath(__file__))
# Initialize components
self.macro_manager = MacroManager(data_file, images_dir, base_dir)
self.web_server = WebServer(self.macro_manager, base_dir, DEFAULT_PORT)
# UI state
self.current_sort = "name"
self.current_tab = "All"
self.image_cache = {}
# Server state
self.server_running = False
self.flask_thread = None
# Tray state
self.tray_icon = None
self.is_closing = False
# Create UI
self.create_ui()
# Set up window event handlers
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
self.root.bind('<Unmap>', self.on_minimize)
# Initialize tray icon
self.create_tray_icon()
def main():
"""Main entry point."""
# Required for multiprocessing on Windows
multiprocessing.freeze_support()
def configure_styles(self):
"""Configure the dark theme styles"""
self.root.configure(bg=THEME['bg_color'])
# Get app directory
app_dir = get_app_dir()
style = ttk.Style()
style.theme_use("clam")
style.configure("TButton", background=THEME['button_bg'], foreground=THEME['button_fg'])
style.map("TButton", background=[("active", THEME['accent_color'])])
style.configure("TFrame", background=THEME['bg_color'])
style.configure("TLabel", background=THEME['bg_color'], foreground=THEME['fg_color'])
# Configure notebook (tabs) style
style.configure("TNotebook", background=THEME['bg_color'], borderwidth=0)
style.configure("TNotebook.Tab", background=THEME['tab_bg'], foreground=THEME['fg_color'],
padding=[12, 8], borderwidth=0)
style.map("TNotebook.Tab",
background=[("selected", THEME['tab_selected'])],
foreground=[("selected", THEME['fg_color'])],
padding=[("selected", [12, 8])]) # Keep same padding when selected
# Import PySide6 after freeze_support
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
def create_ui(self):
"""Create the main user interface"""
# Create main container
main_frame = ttk.Frame(self.root)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Create application
app = QApplication(sys.argv)
app.setApplicationName("MacroPad Server")
app.setOrganizationName("MacroPad")
# Left side: Macro list and controls
left_frame = ttk.Frame(main_frame)
left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Set application icon
icon_path = os.path.join(app_dir, "Macro Pad.png")
if os.path.exists(icon_path):
app.setWindowIcon(QIcon(icon_path))
# Sort controls
self._create_sort_controls(left_frame)
# Create notebook for tabs
self.notebook = ttk.Notebook(left_frame)
self.notebook.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
self.notebook.bind("<<NotebookTabChanged>>", self.on_tab_change)
# Import and create main window
from gui.main_window import MainWindow
window = MainWindow(app_dir)
window.show()
# Button controls
self._create_macro_buttons(left_frame)
# Right side: Server controls
right_frame = ttk.Frame(main_frame)
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(10, 0))
self._create_server_controls(right_frame)
# Version label
version_label = tk.Label(self.root, text=VERSION,
bg=THEME['bg_color'], fg=THEME['fg_color'],
font=('Helvetica', 8))
version_label.pack(side=tk.BOTTOM, anchor=tk.SE, padx=5, pady=(0, 2))
# Initialize display
self.setup_tabs()
self.display_macros()
def _create_sort_controls(self, parent):
"""Create sorting controls"""
sort_frame = ttk.Frame(parent)
sort_frame.pack(fill=tk.X, pady=(0, 10))
ttk.Label(sort_frame, text="Sort by:").pack(side=tk.LEFT, padx=(0, 5))
self.sort_var = tk.StringVar(value=self.current_sort)
sort_combo = ttk.Combobox(sort_frame, textvariable=self.sort_var,
values=["name", "type", "recent"],
state="readonly", width=10)
sort_combo.pack(side=tk.LEFT, padx=(0, 10))
sort_combo.bind("<<ComboboxSelected>>", self.on_sort_change)
# Tab management button
ttk.Button(sort_frame, text="Manage Tabs",
command=self.manage_tabs).pack(side=tk.RIGHT, padx=(5, 0))
def _create_macro_buttons(self, parent):
"""Create macro management buttons"""
button_frame = ttk.Frame(parent)
button_frame.pack(fill=tk.X, pady=(0, 10))
ttk.Button(button_frame, text="Add Macro", command=self.add_macro).pack(side=tk.LEFT, padx=2)
ttk.Button(button_frame, text="Edit Macro", command=self.edit_macro).pack(side=tk.LEFT, padx=2)
ttk.Button(button_frame, text="Delete Macro", command=self.delete_macro).pack(side=tk.LEFT, padx=2)
def _create_server_controls(self, parent):
"""Create web server controls"""
server_frame = ttk.Frame(parent)
server_frame.pack(fill=tk.X, pady=10)
ttk.Label(server_frame, text="Web Server Control:").grid(row=0, column=0, sticky="w", pady=5)
self.server_button = ttk.Button(server_frame, text="Start Web Server", command=self.toggle_server)
self.server_button.grid(row=0, column=1, padx=5, pady=5)
# Status display
self.status_var = tk.StringVar(value="Web server not running")
ttk.Label(server_frame, textvariable=self.status_var).grid(row=1, column=0, columnspan=2, sticky="w", pady=5)
# QR code display
self.qr_label = ttk.Label(parent)
self.qr_label.pack(pady=10)
# URL display
self.url_var = tk.StringVar(value="")
self.url_label = ttk.Label(parent, textvariable=self.url_var)
self.url_label.pack(pady=5)
# Browser button
self.browser_button = ttk.Button(parent, text="Open in Browser",
command=self.open_in_browser, state=tk.DISABLED)
self.browser_button.pack(pady=5)
def setup_tabs(self):
"""Initialize tabs based on macro categories"""
# Clear existing tabs
for tab in self.notebook.tabs():
self.notebook.forget(tab)
# Get unique tabs from macro manager
tabs = self.macro_manager.get_unique_tabs()
for tab_name in tabs:
frame = ttk.Frame(self.notebook)
self.notebook.add(frame, text=tab_name)
def get_current_tab_name(self):
"""Get the name of the currently selected tab"""
try:
current_tab_id = self.notebook.select()
return self.notebook.tab(current_tab_id, "text")
except:
return "All"
def on_tab_change(self, event=None):
"""Handle tab change event"""
self.current_tab = self.get_current_tab_name()
self.display_macros()
def on_sort_change(self, event=None):
"""Handle sort option change"""
self.current_sort = self.sort_var.get()
self.display_macros()
def display_macros(self):
"""Display macros in the current tab"""
# Get current tab frame
try:
current_tab_id = self.notebook.select()
current_frame = self.notebook.nametowidget(current_tab_id)
except:
return
# Clear previous content
for widget in current_frame.winfo_children():
widget.destroy()
# Create scrollable canvas
canvas = tk.Canvas(current_frame, bg=THEME['bg_color'], highlightthickness=0)
scrollbar = ttk.Scrollbar(current_frame, orient="vertical", command=canvas.yview)
scrollable_frame = ttk.Frame(canvas)
scrollable_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
)
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Get sorted and filtered macros
sorted_macros = self.macro_manager.get_sorted_macros(self.current_sort)
filtered_macros = self.macro_manager.filter_macros_by_tab(sorted_macros, self.current_tab)
# Display macros
for macro_id, macro in filtered_macros:
self._create_macro_button(scrollable_frame, macro_id, macro)
# Display message if no macros
if not filtered_macros:
label = tk.Label(scrollable_frame, text="No macros in this category",
bg=THEME['bg_color'], fg=THEME['fg_color'])
label.pack(pady=20)
def _create_macro_button(self, parent, macro_id, macro):
"""Create a button for a single macro"""
frame = ttk.Frame(parent)
frame.pack(fill="x", pady=5, padx=5)
button = tk.Button(
frame, text=macro["name"],
bg=THEME['button_bg'], fg=THEME['button_fg'],
activebackground=THEME['accent_color'], activeforeground=THEME['button_fg'],
relief=tk.RAISED, bd=2, pady=8,
command=lambda: self.macro_manager.execute_macro(macro_id)
)
# Add image if available
if "image_path" in macro and macro["image_path"]:
try:
if macro["image_path"] in self.image_cache:
button_image = self.image_cache[macro["image_path"]]
else:
img_path = os.path.join(self.macro_manager.app_dir, macro["image_path"])
img = Image.open(img_path)
img = img.resize((32, 32))
button_image = ImageTk.PhotoImage(img)
self.image_cache[macro["image_path"]] = button_image
button.config(image=button_image, compound=tk.LEFT)
button.image = button_image # Keep reference
except Exception as e:
print(f"Error loading image for {macro['name']}: {e}")
button.pack(side=tk.LEFT, fill=tk.X, expand=True)
def manage_tabs(self):
"""Open tab management dialog"""
tab_manager = TabManager(self.root, self.macro_manager)
tab_manager.show()
self.setup_tabs()
self.display_macros()
def add_macro(self):
"""Add a new macro"""
dialog = MacroDialog(self.root, self.macro_manager)
result = dialog.show()
if result:
self.setup_tabs()
self.display_macros()
def edit_macro(self):
"""Edit an existing macro"""
selector = MacroSelector(self.root, self.macro_manager, "Select Macro to Edit")
macro_id = selector.show()
if macro_id:
dialog = MacroDialog(self.root, self.macro_manager, macro_id)
result = dialog.show()
if result:
self.setup_tabs()
self.display_macros()
def delete_macro(self):
"""Delete a macro"""
selector = MacroSelector(self.root, self.macro_manager, "Select Macro to Delete")
macro_id = selector.show()
if macro_id:
macro_name = self.macro_manager.macros[macro_id]["name"]
if messagebox.askyesno("Confirm Deletion", f"Are you sure you want to delete macro '{macro_name}'?"):
self.macro_manager.delete_macro(macro_id)
self.setup_tabs()
self.display_macros()
def toggle_server(self):
"""Toggle web server on/off"""
if self.server_running:
self.stop_server()
else:
self.start_server()
def start_server(self):
"""Start the web server"""
try:
if not self.server_running:
self.server_running = True
self.flask_thread = threading.Thread(target=self.run_web_server)
self.flask_thread.daemon = True
self.flask_thread.start()
self.server_button.config(text="Stop Web Server")
# Get IP address and display info
ip_address = self.get_ip_address()
if ip_address:
url = f"http://{ip_address}:{DEFAULT_PORT}"
url_text = f"Web UI available at:\n{url}"
self.url_var.set(url_text)
self.browser_button.config(state=tk.NORMAL)
self.generate_qr_code(url)
else:
self.url_var.set("No network interfaces found")
except Exception as e:
self.status_var.set(f"Error starting server: {e}")
self.server_running = False
def stop_server(self):
"""Stop the web server"""
if self.server_running:
self.server_running = False
self.status_var.set("Web server stopped")
self.server_button.config(text="Start Web Server")
self.url_var.set("")
self.browser_button.config(state=tk.DISABLED)
self.qr_label.config(image="")
def run_web_server(self):
"""Run the web server in a separate thread"""
self.status_var.set(f"Web server running on port {DEFAULT_PORT}")
try:
self.web_server.run()
except Exception as e:
self.status_var.set(f"Web server error: {e}")
self.server_running = False
def get_ip_address(self):
"""Get the primary internal IPv4 address"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception as e:
print(f"Error getting IP address: {e}")
try:
hostname = socket.gethostname()
ip = socket.gethostbyname(hostname)
if not ip.startswith("127."):
return ip
for addr_info in socket.getaddrinfo(hostname, None):
potential_ip = addr_info[4][0]
if '.' in potential_ip and not potential_ip.startswith("127."):
return potential_ip
except:
pass
return "127.0.0.1"
def generate_qr_code(self, url):
"""Generate and display QR code for the URL"""
try:
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(url)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white")
qr_photoimg = ImageTk.PhotoImage(qr_img)
self.qr_label.config(image=qr_photoimg)
self.qr_label.image = qr_photoimg
except ImportError:
self.qr_label.config(text="QR code generation requires 'qrcode' package")
except Exception as e:
print(f"Error generating QR code: {e}")
self.qr_label.config(text="Error generating QR code")
def open_in_browser(self):
"""Open the web interface in browser"""
if self.server_running:
webbrowser.open(f"http://localhost:{DEFAULT_PORT}")
def on_minimize(self, event):
"""Handle window minimize event"""
# Only minimize to tray if the window is being iconified, not just unmapped
if event.widget == self.root and self.root.state() == 'iconic':
self.root.withdraw() # Hide window
def create_tray_icon(self):
"""Create system tray icon"""
try:
# Create a simple icon image with M letter
icon_image = Image.new("RGB", (64, 64), THEME['accent_color'])
draw = ImageDraw.Draw(icon_image)
try:
# Try to use a system font
font = ImageFont.truetype("arial.ttf", 40)
except:
try:
# Try other common fonts
font = ImageFont.truetype("calibri.ttf", 40)
except:
# Fall back to default font
font = ImageFont.load_default()
# Draw "MP" in the center
text = "MP"
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (64 - text_width) // 2
y = (64 - text_height) // 2
draw.text((x, y), text, fill="white", font=font)
menu = (
pystray.MenuItem('Show', self.show_window),
pystray.MenuItem('Exit', self.exit_app)
)
self.tray_icon = pystray.Icon("macropad", icon_image, "MacroPad Server", menu)
# Run tray icon in a separate thread
tray_thread = threading.Thread(target=self.tray_icon.run, daemon=True)
tray_thread.start()
except Exception as e:
print(f"Error creating tray icon: {e}")
# Tray icon is optional, continue without it
def show_window(self, icon=None, item=None):
"""Show window from tray"""
self.root.deiconify()
self.root.state('normal')
self.root.lift()
self.root.focus_force()
def exit_app(self, icon=None, item=None):
"""Exit the application"""
self.is_closing = True
self.stop_server()
if self.tray_icon:
try:
self.tray_icon.stop()
except:
pass
try:
self.root.quit()
except:
pass
# Force exit if needed
import os
os._exit(0)
def on_closing(self):
"""Handle window close event - exit the application"""
self.exit_app()
# Run application
sys.exit(app.exec())
if __name__ == "__main__":
root = tk.Tk()
app = MacroPadServer(root)
root.mainloop()
main()

View File

@@ -1,38 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='main',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

62
pyproject.toml Normal file
View File

@@ -0,0 +1,62 @@
[project]
name = "macropad-server"
version = "0.9.0"
description = "A cross-platform macro management application with desktop and web interfaces"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
authors = [
{name = "jknapp"}
]
keywords = ["macropad", "macro", "automation", "keyboard", "pwa"]
dependencies = [
# Image processing
"pillow>=10.0.0",
# Keyboard/mouse automation
"pyautogui>=0.9.54",
# System tray
"pystray>=0.19.5",
# Web server
"fastapi>=0.104.0",
"uvicorn>=0.24.0",
"websockets>=12.0",
"python-multipart>=0.0.6", # For file uploads
# Network utilities
"netifaces>=0.11.0",
# QR code generation
"qrcode>=7.4.2",
# Desktop GUI
"PySide6>=6.6.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"black>=23.0.0",
"ruff>=0.1.0",
"pyinstaller>=6.0.0",
]
[project.scripts]
macropad-server = "main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["gui"]
[dependency-groups]
dev = [
"pyinstaller>=6.0.0",
]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.black]
line-length = 100
target-version = ["py311"]

View File

@@ -1,7 +0,0 @@
pillow
pyautogui
pystray
flask
waitress
netifaces
qrcode

View File

@@ -1,283 +0,0 @@
# UI components and dialogs for the desktop application
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from PIL import Image, ImageTk
import os
import uuid
from config import THEME
class MacroDialog:
"""Dialog for adding/editing macros"""
def __init__(self, parent, macro_manager, macro_id=None):
self.parent = parent
self.macro_manager = macro_manager
self.macro_id = macro_id
self.dialog = None
self.result = None
def show(self):
"""Show the dialog and return the result"""
self.dialog = tk.Toplevel(self.parent)
self.dialog.title("Edit Macro" if self.macro_id else "Add Macro")
self.dialog.geometry("450x400")
self.dialog.transient(self.parent)
self.dialog.configure(bg=THEME['bg_color'])
self.dialog.grab_set()
# If editing, get existing macro data
if self.macro_id:
macro = self.macro_manager.macros.get(self.macro_id, {})
else:
macro = {}
self._create_widgets(macro)
# Wait for dialog to close
self.dialog.wait_window()
return self.result
def _create_widgets(self, macro):
"""Create the dialog widgets"""
# Name
tk.Label(self.dialog, text="Macro Name:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.name_entry = tk.Entry(self.dialog, width=30, bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
self.name_entry.grid(row=0, column=1, padx=5, pady=5)
self.name_entry.insert(0, macro.get("name", ""))
# Category
tk.Label(self.dialog, text="Category:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=1, column=0, padx=5, pady=5, sticky="w")
self.category_entry = tk.Entry(self.dialog, width=30, bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
self.category_entry.grid(row=1, column=1, padx=5, pady=5)
self.category_entry.insert(0, macro.get("category", ""))
# Type
tk.Label(self.dialog, text="Type:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=2, column=0, padx=5, pady=5, sticky="w")
self.type_var = tk.StringVar(value=macro.get("type", "text"))
radio_style = {'bg': THEME['bg_color'], 'fg': THEME['fg_color'], 'selectcolor': THEME['accent_color']}
tk.Radiobutton(self.dialog, text="Text", variable=self.type_var, value="text", **radio_style).grid(row=2, column=1, sticky="w")
tk.Radiobutton(self.dialog, text="Application", variable=self.type_var, value="app", **radio_style).grid(row=3, column=1, sticky="w")
# Command/Text
tk.Label(self.dialog, text="Command/Text:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=4, column=0, padx=5, pady=5, sticky="w")
self.command_text = tk.Text(self.dialog, width=30, height=5, bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
self.command_text.grid(row=4, column=1, padx=5, pady=5)
self.command_text.insert("1.0", macro.get("command", ""))
# Modifiers
self._create_modifiers(macro)
# Image
self.image_path = tk.StringVar()
tk.Label(self.dialog, text="Image:", bg=THEME['bg_color'], fg=THEME['fg_color']).grid(row=6, column=0, padx=5, pady=5, sticky="w")
image_entry = tk.Entry(self.dialog, textvariable=self.image_path, width=30, bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
image_entry.grid(row=6, column=1, padx=5, pady=5)
button_style = {'bg': THEME['button_bg'], 'fg': THEME['button_fg'], 'activebackground': THEME['accent_color'],
'activeforeground': THEME['button_fg'], 'bd': 0, 'relief': tk.FLAT}
tk.Button(self.dialog, text="Browse...", command=self._browse_image, **button_style).grid(row=6, column=2)
# Buttons
tk.Button(self.dialog, text="Save", command=self._save, **button_style).grid(row=7, column=0, padx=5, pady=20)
tk.Button(self.dialog, text="Cancel", command=self._cancel, **button_style).grid(row=7, column=1, padx=5, pady=20)
def _create_modifiers(self, macro):
"""Create modifier checkboxes"""
mod_frame = tk.Frame(self.dialog, bg=THEME['bg_color'])
mod_frame.grid(row=5, column=0, columnspan=2, sticky="w", padx=5, pady=5)
tk.Label(mod_frame, text="Key Modifiers:", bg=THEME['bg_color'], fg=THEME['fg_color']).pack(side=tk.LEFT, padx=5)
modifiers = macro.get("modifiers", {})
self.ctrl_var = tk.BooleanVar(value=modifiers.get("ctrl", False))
self.alt_var = tk.BooleanVar(value=modifiers.get("alt", False))
self.shift_var = tk.BooleanVar(value=modifiers.get("shift", False))
self.enter_var = tk.BooleanVar(value=modifiers.get("enter", False))
checkbox_style = {'bg': THEME['bg_color'], 'fg': THEME['fg_color'], 'selectcolor': THEME['accent_color'],
'activebackground': THEME['bg_color'], 'activeforeground': THEME['fg_color']}
tk.Checkbutton(mod_frame, text="Ctrl", variable=self.ctrl_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Alt", variable=self.alt_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Shift", variable=self.shift_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
tk.Checkbutton(mod_frame, text="Add Enter", variable=self.enter_var, **checkbox_style).pack(side=tk.LEFT, padx=5)
def _browse_image(self):
"""Browse for image file"""
filename = filedialog.askopenfilename(
filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")])
if filename:
self.image_path.set(filename)
def _save(self):
"""Save the macro"""
name = self.name_entry.get().strip()
if not name:
messagebox.showerror("Error", "Macro name is required")
return
macro_type = self.type_var.get()
command = self.command_text.get("1.0", tk.END).strip()
category = self.category_entry.get().strip()
modifiers = {
"ctrl": self.ctrl_var.get(),
"alt": self.alt_var.get(),
"shift": self.shift_var.get(),
"enter": self.enter_var.get()
}
if self.macro_id:
# Update existing macro
success = self.macro_manager.update_macro(
self.macro_id, name, macro_type, command, category, modifiers, self.image_path.get())
else:
# Add new macro
self.macro_id = self.macro_manager.add_macro(
name, macro_type, command, category, modifiers, self.image_path.get())
success = bool(self.macro_id)
if success:
self.result = self.macro_id
self.dialog.destroy()
else:
messagebox.showerror("Error", "Failed to save macro")
def _cancel(self):
"""Cancel dialog"""
self.result = None
self.dialog.destroy()
class MacroSelector:
"""Dialog for selecting a macro from a list"""
def __init__(self, parent, macro_manager, title="Select Macro"):
self.parent = parent
self.macro_manager = macro_manager
self.title = title
self.result = None
def show(self):
"""Show the selection dialog"""
if not self.macro_manager.macros:
messagebox.showinfo("No Macros", "There are no macros available.")
return None
dialog = tk.Toplevel(self.parent)
dialog.title(self.title)
dialog.geometry("200x340")
dialog.transient(self.parent)
dialog.configure(bg=THEME['bg_color'])
dialog.grab_set()
# Instructions
tk.Label(dialog, text=f"{self.title}:", bg=THEME['bg_color'], fg=THEME['fg_color']).pack(pady=5)
# Listbox
listbox = tk.Listbox(dialog, bg=THEME['highlight_color'], fg=THEME['fg_color'], selectbackground=THEME['accent_color'])
listbox.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# Populate listbox
macro_ids = []
for macro_id, macro in self.macro_manager.macros.items():
listbox.insert(tk.END, macro["name"])
macro_ids.append(macro_id)
def on_select():
if not listbox.curselection():
messagebox.showwarning("No Selection", f"Please select a macro.")
return
idx = listbox.curselection()[0]
self.result = macro_ids[idx]
dialog.destroy()
button_style = {'bg': THEME['button_bg'], 'fg': THEME['button_fg'], 'activebackground': THEME['accent_color'],
'activeforeground': THEME['button_fg'], 'bd': 0, 'relief': tk.FLAT}
tk.Button(dialog, text="Select", command=on_select, **button_style).pack(pady=10)
tk.Button(dialog, text="Cancel", command=lambda: dialog.destroy(), **button_style).pack(pady=5)
dialog.wait_window()
return self.result
class TabManager:
"""Dialog for managing macro categories/tabs"""
def __init__(self, parent, macro_manager):
self.parent = parent
self.macro_manager = macro_manager
def show(self):
"""Show tab management dialog"""
dialog = tk.Toplevel(self.parent)
dialog.title("Manage Tabs")
dialog.geometry("450x400") # Increased width and height
dialog.transient(self.parent)
dialog.configure(bg=THEME['bg_color'])
dialog.grab_set()
# Instructions
tk.Label(dialog, text="Assign categories to macros for better organization:",
bg=THEME['bg_color'], fg=THEME['fg_color']).pack(pady=10)
# Create scrollable frame
list_frame = tk.Frame(dialog, bg=THEME['bg_color'])
list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
canvas = tk.Canvas(list_frame, bg=THEME['bg_color'], highlightthickness=0)
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
scrollable_frame = tk.Frame(canvas, bg=THEME['bg_color'])
scrollable_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
)
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Category entries for each macro
category_vars = {}
for macro_id, macro in self.macro_manager.macros.items():
frame = tk.Frame(scrollable_frame, bg=THEME['bg_color'])
frame.pack(fill="x", pady=2, padx=5)
tk.Label(frame, text=macro["name"], bg=THEME['bg_color'], fg=THEME['fg_color'],
width=20, anchor="w").pack(side=tk.LEFT)
category_var = tk.StringVar(value=macro.get("category", ""))
category_vars[macro_id] = category_var
entry = tk.Entry(frame, textvariable=category_var, width=15,
bg=THEME['highlight_color'], fg=THEME['fg_color'], insertbackground=THEME['fg_color'])
entry.pack(side=tk.RIGHT, padx=(5, 0))
# Buttons - use a fixed frame at bottom
button_frame = tk.Frame(dialog, bg=THEME['bg_color'])
button_frame.pack(side=tk.BOTTOM, fill=tk.X, pady=10)
def save_categories():
for macro_id, category_var in category_vars.items():
category = category_var.get().strip()
if category:
self.macro_manager.macros[macro_id]["category"] = category
else:
self.macro_manager.macros[macro_id].pop("category", None)
self.macro_manager.save_macros()
dialog.destroy()
button_style = {'bg': THEME['button_bg'], 'fg': THEME['button_fg'], 'activebackground': THEME['accent_color'],
'activeforeground': THEME['button_fg'], 'bd': 0, 'relief': tk.FLAT}
tk.Button(button_frame, text="Save", command=save_categories, **button_style).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="Cancel", command=lambda: dialog.destroy(), **button_style).pack(side=tk.LEFT, padx=5)
dialog.wait_window()

View File

@@ -1 +1 @@
0.8.5
0.9.0

546
web/css/styles.css Normal file
View File

@@ -0,0 +1,546 @@
/* MacroPad PWA Styles */
:root {
--bg-color: #2e2e2e;
--fg-color: #ffffff;
--highlight-color: #3e3e3e;
--accent-color: #007acc;
--button-bg: #505050;
--button-hover: #606060;
--tab-bg: #404040;
--tab-selected: #007acc;
--danger-color: #dc3545;
--success-color: #28a745;
--warning-color: #ffc107;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--bg-color);
color: var(--fg-color);
min-height: 100vh;
overflow-x: hidden;
}
/* Header */
.header {
background-color: var(--highlight-color);
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.header-btn {
background: var(--accent-color);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.header-btn:hover {
background: #0096ff;
}
.header-btn.secondary {
background: var(--button-bg);
}
.header-btn.secondary:hover {
background: var(--button-hover);
}
/* Tabs */
.tabs {
display: flex;
gap: 0.25rem;
padding: 0.5rem 1rem;
background: var(--bg-color);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab {
background: var(--tab-bg);
color: var(--fg-color);
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s;
}
.tab:hover {
background: var(--button-hover);
}
.tab.active {
background: var(--tab-selected);
}
/* Macro Grid */
.macro-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
padding: 1rem;
}
.macro-card {
background: var(--button-bg);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
transition: transform 0.1s, background 0.2s;
min-height: 120px;
}
.macro-card:hover {
background: var(--button-hover);
transform: translateY(-2px);
}
.macro-card:active {
transform: translateY(0);
}
.macro-card.executing {
animation: pulse 0.3s ease-in-out;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(0.95); background: var(--accent-color); }
100% { transform: scale(1); }
}
.macro-image {
width: 64px;
height: 64px;
object-fit: contain;
margin-bottom: 0.5rem;
border-radius: 4px;
}
.macro-image-placeholder {
width: 64px;
height: 64px;
background: var(--highlight-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
font-size: 1.5rem;
}
.macro-name {
text-align: center;
font-size: 0.9rem;
word-break: break-word;
}
.macro-edit-btn {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.macro-card {
position: relative;
}
.macro-card:hover .macro-edit-btn {
opacity: 1;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.modal {
background: var(--highlight-color);
border-radius: 8px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--bg-color);
}
.modal-header h2 {
font-size: 1.2rem;
}
.modal-close {
background: none;
border: none;
color: var(--fg-color);
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem;
}
.modal-body {
padding: 1rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid var(--bg-color);
}
/* Form Elements */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.9rem;
color: #aaa;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.5rem;
background: var(--bg-color);
border: 1px solid var(--button-bg);
border-radius: 4px;
color: var(--fg-color);
font-size: 1rem;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-color);
}
/* Command Builder */
.command-list {
background: var(--bg-color);
border-radius: 4px;
padding: 0.5rem;
min-height: 100px;
max-height: 300px;
overflow-y: auto;
}
.command-item {
background: var(--button-bg);
border-radius: 4px;
padding: 0.5rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.command-item:last-child {
margin-bottom: 0;
}
.command-type {
background: var(--accent-color);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
text-transform: uppercase;
min-width: 50px;
text-align: center;
}
.command-value {
flex: 1;
font-family: monospace;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-actions {
display: flex;
gap: 0.25rem;
}
.command-actions button {
background: var(--highlight-color);
border: none;
color: var(--fg-color);
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.command-actions button:hover {
background: var(--button-hover);
}
.command-actions button.delete {
color: var(--danger-color);
}
.add-command-btns {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.add-command-btn {
background: var(--button-bg);
border: none;
color: var(--fg-color);
padding: 0.5rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.2s;
}
.add-command-btn:hover {
background: var(--accent-color);
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover {
background: #0096ff;
}
.btn-secondary {
background: var(--button-bg);
color: white;
}
.btn-secondary:hover {
background: var(--button-hover);
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
background: #c82333;
}
/* Status/Toast Messages */
.toast-container {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 300;
}
.toast {
background: var(--highlight-color);
color: var(--fg-color);
padding: 0.75rem 1rem;
border-radius: 4px;
margin-top: 0.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s ease-out;
}
.toast.success {
border-left: 4px solid var(--success-color);
}
.toast.error {
border-left: 4px solid var(--danger-color);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Connection Status */
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #aaa;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger-color);
}
.status-dot.connected {
background: var(--success-color);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #888;
}
.empty-state p {
margin-bottom: 1rem;
}
/* Loading */
.loading {
display: flex;
justify-content: center;
padding: 2rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--button-bg);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Mobile Optimizations */
@media (max-width: 480px) {
.header h1 {
font-size: 1.2rem;
}
.macro-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.75rem;
padding: 0.75rem;
}
.macro-card {
padding: 0.75rem;
min-height: 100px;
}
.macro-image,
.macro-image-placeholder {
width: 48px;
height: 48px;
}
.modal {
max-width: 100%;
margin: 0.5rem;
}
}
/* Install Banner */
.install-banner {
background: var(--accent-color);
color: white;
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.install-banner button {
background: white;
color: var(--accent-color);
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
.install-banner .dismiss {
background: transparent;
color: white;
padding: 0.5rem;
}

BIN
web/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
web/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

91
web/index.html Normal file
View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#007acc">
<meta name="description" content="Remote macro control for your desktop">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="MacroPad">
<title>MacroPad</title>
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<!-- Header -->
<header class="header">
<h1>MacroPad</h1>
<div class="header-actions">
<div class="connection-status">
<div class="status-dot"></div>
<span>Disconnected</span>
</div>
<button class="header-btn secondary" onclick="app.refresh()">Refresh</button>
<button class="header-btn" onclick="app.openAddModal()">+ Add</button>
</div>
</header>
<!-- Tabs -->
<nav class="tabs" id="tabs-container">
<!-- Tabs rendered dynamically -->
</nav>
<!-- Macro Grid -->
<main class="macro-grid" id="macro-grid">
<div class="loading">
<div class="spinner"></div>
</div>
</main>
<!-- Modal -->
<div class="modal-overlay" id="modal-overlay" style="display: none;">
<div class="modal">
<div class="modal-header">
<h2 id="modal-title">Add Macro</h2>
<button class="modal-close" onclick="app.closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="macro-name">Name</label>
<input type="text" id="macro-name" placeholder="Macro name">
</div>
<div class="form-group">
<label for="macro-category">Category (optional)</label>
<input type="text" id="macro-category" placeholder="Category">
</div>
<div class="form-group">
<label>Commands</label>
<div class="command-list" id="command-list">
<div class="empty-state"><p>No commands added yet</p></div>
</div>
<div class="add-command-btns">
<button class="add-command-btn" onclick="app.addCommand('text')">+ Text</button>
<button class="add-command-btn" onclick="app.addCommand('key')">+ Key</button>
<button class="add-command-btn" onclick="app.addCommand('hotkey')">+ Hotkey</button>
<button class="add-command-btn" onclick="app.addCommand('wait')">+ Wait</button>
<button class="add-command-btn" onclick="app.addCommand('app')">+ App</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger" id="delete-btn" style="display: none;"
onclick="app.deleteMacro(app.editingMacroId); app.closeModal();">
Delete
</button>
<button class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="app.saveMacro()">Save</button>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toast-container"></div>
<script src="/static/js/app.js"></script>
</body>
</html>

481
web/js/app.js Normal file
View File

@@ -0,0 +1,481 @@
// MacroPad PWA Application
class MacroPadApp {
constructor() {
this.macros = {};
this.tabs = [];
this.currentTab = 'All';
this.ws = null;
this.editingMacroId = null;
this.commands = [];
this.init();
}
async init() {
await this.loadTabs();
await this.loadMacros();
this.setupWebSocket();
this.setupEventListeners();
this.checkInstallPrompt();
}
// API Methods
async loadTabs() {
try {
const response = await fetch('/api/tabs');
const data = await response.json();
this.tabs = data.tabs || [];
this.renderTabs();
} catch (error) {
console.error('Error loading tabs:', error);
this.showToast('Error loading tabs', 'error');
}
}
async loadMacros() {
try {
const url = this.currentTab === 'All'
? '/api/macros'
: `/api/macros/${encodeURIComponent(this.currentTab)}`;
const response = await fetch(url);
const data = await response.json();
this.macros = data.macros || {};
this.renderMacros();
} catch (error) {
console.error('Error loading macros:', error);
this.showToast('Error loading macros', 'error');
}
}
async executeMacro(macroId) {
try {
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
if (card) card.classList.add('executing');
const response = await fetch('/api/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ macro_id: macroId })
});
if (!response.ok) throw new Error('Execution failed');
setTimeout(() => {
if (card) card.classList.remove('executing');
}, 300);
} catch (error) {
console.error('Error executing macro:', error);
this.showToast('Error executing macro', 'error');
}
}
async saveMacro() {
const name = document.getElementById('macro-name').value.trim();
const category = document.getElementById('macro-category').value.trim();
if (!name) {
this.showToast('Please enter a macro name', 'error');
return;
}
if (this.commands.length === 0) {
this.showToast('Please add at least one command', 'error');
return;
}
const macroData = {
name,
category,
commands: this.commands
};
try {
let response;
if (this.editingMacroId) {
response = await fetch(`/api/macros/${this.editingMacroId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(macroData)
});
} else {
response = await fetch('/api/macros', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(macroData)
});
}
if (!response.ok) throw new Error('Save failed');
this.closeModal();
await this.loadTabs();
await this.loadMacros();
this.showToast('Macro saved successfully', 'success');
} catch (error) {
console.error('Error saving macro:', error);
this.showToast('Error saving macro', 'error');
}
}
async deleteMacro(macroId) {
if (!confirm('Are you sure you want to delete this macro?')) return;
try {
const response = await fetch(`/api/macros/${macroId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Delete failed');
await this.loadTabs();
await this.loadMacros();
this.showToast('Macro deleted', 'success');
} catch (error) {
console.error('Error deleting macro:', error);
this.showToast('Error deleting macro', 'error');
}
}
// WebSocket
setupWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.updateConnectionStatus(true);
};
this.ws.onclose = () => {
this.updateConnectionStatus(false);
setTimeout(() => this.setupWebSocket(), 3000);
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
};
this.ws.onerror = () => {
this.updateConnectionStatus(false);
};
} catch (error) {
console.error('WebSocket error:', error);
}
}
handleWebSocketMessage(data) {
switch (data.type) {
case 'macro_created':
case 'macro_updated':
case 'macro_deleted':
this.loadTabs();
this.loadMacros();
break;
case 'executed':
const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`);
if (card) {
card.classList.add('executing');
setTimeout(() => card.classList.remove('executing'), 300);
}
break;
}
}
updateConnectionStatus(connected) {
const dot = document.querySelector('.status-dot');
const text = document.querySelector('.connection-status span');
if (dot) {
dot.classList.toggle('connected', connected);
}
if (text) {
text.textContent = connected ? 'Connected' : 'Disconnected';
}
}
// Rendering
renderTabs() {
const container = document.getElementById('tabs-container');
if (!container) return;
container.innerHTML = this.tabs.map(tab => `
<button class="tab ${tab === this.currentTab ? 'active' : ''}"
data-tab="${tab}">${tab}</button>
`).join('');
}
renderMacros() {
const container = document.getElementById('macro-grid');
if (!container) return;
const macroEntries = Object.entries(this.macros);
if (macroEntries.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>No macros found</p>
<button class="btn btn-primary" onclick="app.openAddModal()">
Add Your First Macro
</button>
</div>
`;
return;
}
container.innerHTML = macroEntries.map(([id, macro]) => {
const imageSrc = macro.image_path
? `/api/image/${macro.image_path}`
: null;
const firstChar = macro.name.charAt(0).toUpperCase();
return `
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
<button class="macro-edit-btn" onclick="event.stopPropagation(); app.openEditModal('${id}')">
Edit
</button>
${imageSrc
? `<img src="${imageSrc}" alt="${macro.name}" class="macro-image" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
: ''
}
<div class="macro-image-placeholder" ${imageSrc ? 'style="display:none"' : ''}>
${firstChar}
</div>
<span class="macro-name">${macro.name}</span>
</div>
`;
}).join('');
}
renderCommandList() {
const container = document.getElementById('command-list');
if (!container) return;
if (this.commands.length === 0) {
container.innerHTML = '<div class="empty-state"><p>No commands added yet</p></div>';
return;
}
container.innerHTML = this.commands.map((cmd, index) => {
let displayValue = '';
switch (cmd.type) {
case 'text':
displayValue = cmd.value || '';
break;
case 'key':
displayValue = cmd.value || '';
break;
case 'hotkey':
displayValue = (cmd.keys || []).join(' + ');
break;
case 'wait':
displayValue = `${cmd.ms || 0}ms`;
break;
case 'app':
displayValue = cmd.command || '';
break;
}
return `
<div class="command-item" data-index="${index}">
<span class="command-type">${cmd.type}</span>
<span class="command-value">${displayValue}</span>
<div class="command-actions">
<button onclick="app.moveCommand(${index}, -1)">Up</button>
<button onclick="app.moveCommand(${index}, 1)">Down</button>
<button class="delete" onclick="app.removeCommand(${index})">X</button>
</div>
</div>
`;
}).join('');
}
// Command Builder
addCommand(type) {
let command = { type };
switch (type) {
case 'text':
const text = prompt('Enter text to type:');
if (!text) return;
command.value = text;
break;
case 'key':
const key = prompt('Enter key to press (e.g., enter, tab, escape, space):');
if (!key) return;
command.value = key.toLowerCase();
break;
case 'hotkey':
const keys = prompt('Enter key combination (comma separated, e.g., ctrl,c):');
if (!keys) return;
command.keys = keys.split(',').map(k => k.trim().toLowerCase());
break;
case 'wait':
const ms = prompt('Enter delay in milliseconds:');
if (!ms) return;
command.ms = parseInt(ms, 10) || 0;
break;
case 'app':
const appCmd = prompt('Enter application command:');
if (!appCmd) return;
command.command = appCmd;
break;
}
this.commands.push(command);
this.renderCommandList();
}
removeCommand(index) {
this.commands.splice(index, 1);
this.renderCommandList();
}
moveCommand(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= this.commands.length) return;
[this.commands[index], this.commands[newIndex]] =
[this.commands[newIndex], this.commands[index]];
this.renderCommandList();
}
// Modal
openAddModal() {
this.editingMacroId = null;
this.commands = [];
document.getElementById('macro-name').value = '';
document.getElementById('macro-category').value = '';
document.getElementById('modal-title').textContent = 'Add Macro';
document.getElementById('delete-btn').style.display = 'none';
this.renderCommandList();
document.getElementById('modal-overlay').style.display = 'flex';
}
async openEditModal(macroId) {
this.editingMacroId = macroId;
const macro = this.macros[macroId];
if (!macro) return;
document.getElementById('macro-name').value = macro.name || '';
document.getElementById('macro-category').value = macro.category || '';
document.getElementById('modal-title').textContent = 'Edit Macro';
document.getElementById('delete-btn').style.display = 'block';
this.commands = JSON.parse(JSON.stringify(macro.commands || []));
this.renderCommandList();
document.getElementById('modal-overlay').style.display = 'flex';
}
closeModal() {
document.getElementById('modal-overlay').style.display = 'none';
this.editingMacroId = null;
this.commands = [];
}
// Event Listeners
setupEventListeners() {
// Tab clicks
document.getElementById('tabs-container')?.addEventListener('click', (e) => {
if (e.target.classList.contains('tab')) {
this.currentTab = e.target.dataset.tab;
this.renderTabs();
this.loadMacros();
}
});
// Modal close on overlay click
document.getElementById('modal-overlay')?.addEventListener('click', (e) => {
if (e.target.id === 'modal-overlay') {
this.closeModal();
}
});
// Escape key to close modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closeModal();
}
});
}
// Toast notifications
showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
// PWA Install Prompt
checkInstallPrompt() {
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
this.showInstallBanner(deferredPrompt);
});
}
showInstallBanner(deferredPrompt) {
const banner = document.createElement('div');
banner.className = 'install-banner';
banner.innerHTML = `
<span>Install MacroPad for quick access</span>
<div>
<button onclick="app.installPWA()">Install</button>
<button class="dismiss" onclick="this.parentElement.parentElement.remove()">X</button>
</div>
`;
document.body.insertBefore(banner, document.body.firstChild);
this.deferredPrompt = deferredPrompt;
}
async installPWA() {
if (!this.deferredPrompt) return;
this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
if (outcome === 'accepted') {
document.querySelector('.install-banner')?.remove();
}
this.deferredPrompt = null;
}
// Refresh
refresh() {
this.loadTabs();
this.loadMacros();
}
}
// Initialize app
let app;
document.addEventListener('DOMContentLoaded', () => {
app = new MacroPadApp();
});
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then((registration) => {
console.log('SW registered:', registration.scope);
})
.catch((error) => {
console.log('SW registration failed:', error);
});
});
}

25
web/manifest.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "MacroPad Server",
"short_name": "MacroPad",
"description": "Remote macro control for your desktop",
"start_url": "/",
"display": "standalone",
"background_color": "#2e2e2e",
"theme_color": "#007acc",
"orientation": "any",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["utilities", "productivity"]
}

72
web/service-worker.js Normal file
View File

@@ -0,0 +1,72 @@
// MacroPad PWA Service Worker
const CACHE_NAME = 'macropad-v1';
const ASSETS_TO_CACHE = [
'/',
'/static/css/styles.css',
'/static/js/app.js',
'/static/icons/icon-192.png',
'/static/icons/icon-512.png',
'/manifest.json'
];
// Install event - cache assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Caching app assets');
return cache.addAll(ASSETS_TO_CACHE);
})
.then(() => self.skipWaiting())
);
});
// Activate event - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
}).then(() => self.clients.claim())
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Always fetch API requests from network
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
event.respondWith(fetch(event.request));
return;
}
// For other requests, try cache first, then network
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((networkResponse) => {
// Cache successful responses
if (networkResponse && networkResponse.status === 200) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return networkResponse;
});
})
.catch(() => {
// Return offline fallback for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('/');
}
})
);
});

View File

@@ -1,99 +1,272 @@
# Web server component for MacroPad
# FastAPI web server for MacroPad
from flask import Flask, render_template_string, request, jsonify, send_file
from waitress import serve
import logging
import os
from web_templates import INDEX_HTML
import asyncio
from typing import List, Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel
import uvicorn
from config import DEFAULT_PORT, VERSION
class Command(BaseModel):
"""Single command in a macro sequence."""
type: str # text, key, hotkey, wait, app
value: Optional[str] = None
keys: Optional[List[str]] = None
ms: Optional[int] = None
command: Optional[str] = None
class MacroCreate(BaseModel):
"""Request model for creating a macro."""
name: str
commands: List[Command]
category: Optional[str] = ""
class MacroUpdate(BaseModel):
"""Request model for updating a macro."""
name: str
commands: List[Command]
category: Optional[str] = ""
class ExecuteRequest(BaseModel):
"""Request model for executing a macro."""
macro_id: str
class TabCreate(BaseModel):
"""Request model for creating a tab."""
name: str
class ConnectionManager:
"""Manages WebSocket connections for real-time updates."""
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
"""Send message to all connected clients."""
for connection in self.active_connections:
try:
await connection.send_json(message)
except Exception:
pass
class WebServer:
def __init__(self, macro_manager, app_dir, port=40000):
"""FastAPI-based web server for MacroPad."""
def __init__(self, macro_manager, app_dir: str, port: int = DEFAULT_PORT):
self.macro_manager = macro_manager
self.app_dir = app_dir
self.app_dir = app_dir
self.port = port
self.app = None
def create_app(self):
"""Create and configure Flask application"""
app = Flask(__name__)
# Disable Flask's logging except for errors
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)
@app.route('/')
def index():
return render_template_string(INDEX_HTML)
@app.route('/api/tabs')
def get_tabs():
"""Get all available tabs (similar to setup_tabs logic)"""
tabs = ["All"]
# Add tabs based on macro types and custom categories
unique_types = set()
for macro in self.macro_manager.macros.values():
if macro.get("type"):
unique_types.add(macro["type"].title())
# Check for custom category
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)
return jsonify(tabs)
@app.route('/api/macros')
def get_macros():
return jsonify(self.macro_manager.macros)
@app.route('/api/macros/<tab_name>')
def get_macros_by_tab(tab_name):
"""Filter macros by tab (similar to filter_macros_by_tab logic)"""
if tab_name == "All":
return jsonify(self.macro_manager.macros)
filtered_macros = {}
for macro_id, macro in self.macro_manager.macros.items():
# Check type match
if macro.get("type", "").title() == tab_name:
filtered_macros[macro_id] = macro
# Check custom category match
elif macro.get("category") == tab_name:
filtered_macros[macro_id] = macro
return jsonify(filtered_macros)
@app.route('/api/image/<path:image_path>')
def get_image(image_path):
self.manager = ConnectionManager()
self.server = None
def create_app(self) -> FastAPI:
"""Create FastAPI application."""
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
app = FastAPI(
title="MacroPad Server",
version=VERSION,
lifespan=lifespan
)
# Serve static files from web directory
web_dir = os.path.join(self.app_dir, "web")
if os.path.exists(web_dir):
app.mount("/static", StaticFiles(directory=web_dir), name="static")
# Serve macro images
images_dir = os.path.join(self.app_dir, "macro_images")
if os.path.exists(images_dir):
app.mount("/images", StaticFiles(directory=images_dir), name="images")
@app.get("/", response_class=HTMLResponse)
async def index():
"""Serve the main PWA page."""
index_path = os.path.join(web_dir, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path, media_type="text/html")
return HTMLResponse("<h1>MacroPad Server</h1><p>Web interface not found.</p>")
@app.get("/manifest.json")
async def manifest():
"""Serve PWA manifest."""
manifest_path = os.path.join(web_dir, "manifest.json")
if os.path.exists(manifest_path):
return FileResponse(manifest_path, media_type="application/json")
raise HTTPException(status_code=404, detail="Manifest not found")
@app.get("/service-worker.js")
async def service_worker():
"""Serve service worker."""
sw_path = os.path.join(web_dir, "service-worker.js")
if os.path.exists(sw_path):
return FileResponse(sw_path, media_type="application/javascript")
raise HTTPException(status_code=404, detail="Service worker not found")
@app.get("/api/tabs")
async def get_tabs():
"""Get available tab categories."""
return {"tabs": self.macro_manager.get_unique_tabs()}
@app.get("/api/macros")
async def get_macros():
"""Get all macros."""
macros = self.macro_manager.get_all_macros()
return {"macros": macros}
@app.get("/api/macros/{tab}")
async def get_macros_by_tab(tab: str):
"""Get macros filtered by tab/category."""
all_macros = self.macro_manager.get_sorted_macros()
filtered = self.macro_manager.filter_macros_by_tab(all_macros, tab)
return {"macros": dict(filtered)}
@app.get("/api/macro/{macro_id}")
async def get_macro(macro_id: str):
"""Get a single macro by ID."""
macro = self.macro_manager.get_macro(macro_id)
if macro:
return {"macro": macro}
raise HTTPException(status_code=404, detail="Macro not found")
@app.post("/api/execute")
async def execute_macro(request: ExecuteRequest):
"""Execute a macro by ID."""
success = self.macro_manager.execute_macro(request.macro_id)
if success:
# Broadcast execution to all connected clients
await self.manager.broadcast({
"type": "executed",
"macro_id": request.macro_id
})
return {"success": True}
raise HTTPException(status_code=404, detail="Macro not found or execution failed")
@app.post("/api/macros")
async def create_macro(macro: MacroCreate):
"""Create a new macro."""
commands = [cmd.model_dump(exclude_none=True) for cmd in macro.commands]
macro_id = self.macro_manager.add_macro(
name=macro.name,
commands=commands,
category=macro.category or ""
)
# Broadcast update
await self.manager.broadcast({"type": "macro_created", "macro_id": macro_id})
return {"success": True, "macro_id": macro_id}
@app.put("/api/macros/{macro_id}")
async def update_macro(macro_id: str, macro: MacroUpdate):
"""Update an existing macro."""
commands = [cmd.model_dump(exclude_none=True) for cmd in macro.commands]
success = self.macro_manager.update_macro(
macro_id=macro_id,
name=macro.name,
commands=commands,
category=macro.category or ""
)
if success:
await self.manager.broadcast({"type": "macro_updated", "macro_id": macro_id})
return {"success": True}
raise HTTPException(status_code=404, detail="Macro not found")
@app.delete("/api/macros/{macro_id}")
async def delete_macro(macro_id: str):
"""Delete a macro."""
success = self.macro_manager.delete_macro(macro_id)
if success:
await self.manager.broadcast({"type": "macro_deleted", "macro_id": macro_id})
return {"success": True}
raise HTTPException(status_code=404, detail="Macro not found")
@app.post("/api/upload-image")
async def upload_image(file: UploadFile = File(...)):
"""Upload an image for a macro."""
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
# Save to temp location
import tempfile
import shutil
ext = os.path.splitext(file.filename)[1] if file.filename else ".png"
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
shutil.copyfileobj(file.file, tmp)
return {"path": tmp.name}
@app.get("/api/image/{image_path:path}")
async def get_image(image_path: str):
"""Get macro image (legacy compatibility)."""
full_path = os.path.join(self.app_dir, image_path)
if os.path.exists(full_path):
return FileResponse(full_path)
raise HTTPException(status_code=404, detail="Image not found")
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket for real-time updates."""
await self.manager.connect(websocket)
try:
image_path = os.path.join(self.app_dir, image_path)
return send_file(image_path)
except Exception as e:
return str(e), 404
@app.route('/api/execute', methods=['POST'])
def execute_macro():
data = request.get_json()
if not data or 'macro_id' not in data:
return jsonify({"success": False, "error": "Invalid request"})
macro_id = data['macro_id']
success = self.macro_manager.execute_macro(macro_id)
return jsonify({"success": success})
while True:
data = await websocket.receive_json()
# Handle incoming messages if needed
if data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
self.manager.disconnect(websocket)
self.app = app
return app
def run(self):
"""Run the web server"""
"""Run the web server (blocking)."""
if not self.app:
self.create_app()
try:
serve(self.app, host='0.0.0.0', port=self.port, threads=4)
except Exception as e:
raise e
config = uvicorn.Config(
self.app,
host="0.0.0.0",
port=self.port,
log_level="warning"
)
server = uvicorn.Server(config)
server.run()
async def run_async(self):
"""Run the web server asynchronously."""
if not self.app:
self.create_app()
config = uvicorn.Config(
self.app,
host="0.0.0.0",
port=self.port,
log_level="warning"
)
self.server = uvicorn.Server(config)
await self.server.serve()

View File

@@ -1,292 +0,0 @@
# HTML templates for the web interface
INDEX_HTML = '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MacroPad Web Interface</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #2e2e2e;
color: #ffffff;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
h1 {
color: #007acc;
margin: 0;
}
.refresh-button {
background-color: #505050;
color: white;
border: none;
border-radius: 8px;
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
align-items: center;
}
.refresh-button:hover {
background-color: #007acc;
}
.refresh-button svg {
margin-right: 5px;
}
.tab-container {
margin-bottom: 20px;
border-bottom: 2px solid #404040;
}
.tab-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 0;
}
.tab-button {
background-color: #404040;
color: #ffffff;
border: none;
border-radius: 8px 8px 0 0;
padding: 10px 15px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.3s;
border-bottom: 3px solid transparent;
}
.tab-button:hover {
background-color: #505050;
}
.tab-button.active {
background-color: #007acc;
border-bottom-color: #007acc;
}
.macro-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
margin-top: 20px;
}
.macro-button {
background-color: #505050;
color: white;
border: none;
border-radius: 8px;
padding: 15px 10px;
text-align: center;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100px;
}
.macro-button:hover, .macro-button:active {
background-color: #007acc;
}
.macro-button img {
max-width: 64px;
max-height: 64px;
margin-bottom: 10px;
}
.status {
text-align: center;
margin: 20px 0;
padding: 10px;
border-radius: 5px;
}
.success {
background-color: #4CAF50;
color: white;
display: none;
}
.error {
background-color: #f44336;
color: white;
display: none;
}
@media (max-width: 600px) {
.macro-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.macro-button {
padding: 10px 5px;
font-size: 14px;
}
h1 {
font-size: 24px;
}
.refresh-button {
padding: 8px 12px;
font-size: 14px;
}
.tab-button {
padding: 8px 12px;
font-size: 12px;
}
}
</style>
</head>
<body>
<div class="header-container">
<h1>MacroPad Web Interface</h1>
<button class="refresh-button" onclick="loadTabs()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
Refresh
</button>
</div>
<div class="status success" id="success-status">Macro executed successfully!</div>
<div class="status error" id="error-status">Failed to execute macro</div>
<div class="tab-container">
<div class="tab-list" id="tab-list">
<!-- Tabs will be loaded here -->
</div>
</div>
<div class="macro-grid" id="macro-grid">
<!-- Macros will be loaded here -->
</div>
<script>
let currentTab = 'All';
let allMacros = {};
document.addEventListener('DOMContentLoaded', function() {
loadTabs();
});
function loadTabs() {
fetch('/api/tabs')
.then(response => response.json())
.then(tabs => {
const tabList = document.getElementById('tab-list');
tabList.innerHTML = '';
tabs.forEach(tab => {
const button = document.createElement('button');
button.className = 'tab-button';
button.textContent = tab;
button.onclick = function() { switchTab(tab); };
if (tab === currentTab) {
button.classList.add('active');
}
tabList.appendChild(button);
});
// Load macros for current tab
loadMacros(currentTab);
})
.catch(error => {
console.error('Error loading tabs:', error);
});
}
function switchTab(tabName) {
currentTab = tabName;
// Update tab button states
const tabButtons = document.querySelectorAll('.tab-button');
tabButtons.forEach(button => {
button.classList.remove('active');
if (button.textContent === tabName) {
button.classList.add('active');
}
});
// Load macros for selected tab
loadMacros(tabName);
}
function loadMacros(tab = 'All') {
const url = tab === 'All' ? '/api/macros' : `/api/macros/${encodeURIComponent(tab)}`;
fetch(url)
.then(response => response.json())
.then(macros => {
allMacros = macros;
displayMacros(macros);
})
.catch(error => {
console.error('Error loading macros:', error);
});
}
function displayMacros(macros) {
const macroGrid = document.getElementById('macro-grid');
macroGrid.innerHTML = '';
if (Object.keys(macros).length === 0) {
macroGrid.innerHTML = '<p style="grid-column: 1 / -1; text-align: center;">No macros in this category.</p>';
return;
}
for (const [macroId, macro] of Object.entries(macros)) {
const button = document.createElement('button');
button.className = 'macro-button';
button.onclick = function() { executeMacro(macroId); };
// Add image if available
if (macro.image_path) {
const img = document.createElement('img');
img.src = `/api/image/${encodeURIComponent(macro.image_path)}`;
img.alt = macro.name;
img.onerror = function() {
this.style.display = 'none';
};
button.appendChild(img);
}
const text = document.createTextNode(macro.name);
button.appendChild(text);
macroGrid.appendChild(button);
}
}
function executeMacro(macroId) {
fetch('/api/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ macro_id: macroId })
})
.then(response => response.json())
.then(data => {
const successStatus = document.getElementById('success-status');
const errorStatus = document.getElementById('error-status');
if (data.success) {
successStatus.style.display = 'block';
errorStatus.style.display = 'none';
setTimeout(() => { successStatus.style.display = 'none'; }, 2000);
} else {
errorStatus.style.display = 'block';
successStatus.style.display = 'none';
setTimeout(() => { errorStatus.style.display = 'none'; }, 2000);
}
})
.catch(error => {
console.error('Error executing macro:', error);
const errorStatus = document.getElementById('error-status');
errorStatus.style.display = 'block';
setTimeout(() => { errorStatus.style.display = 'none'; }, 2000);
});
}
</script>
</body>
</html>
'''