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:
@@ -1,157 +1,159 @@
|
|||||||
name: Build and Release
|
# =============================================================================
|
||||||
|
# WORKFLOW DISABLED - Pending testing of v0.9.0 modernization
|
||||||
on:
|
# =============================================================================
|
||||||
push:
|
# This workflow is temporarily disabled while testing the new:
|
||||||
branches:
|
# - PySide6 GUI (replacing Tkinter)
|
||||||
- main
|
# - FastAPI web server (replacing Flask)
|
||||||
|
# - pyproject.toml build system (replacing requirements.txt)
|
||||||
jobs:
|
# - PWA web interface
|
||||||
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
|
|
||||||
#
|
#
|
||||||
# build-macos:
|
# Uncomment the workflow below once builds are verified locally.
|
||||||
# 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
|
|
||||||
|
|
||||||
attach-to-release:
|
# name: Build and Release
|
||||||
needs: [create-release, build-windows, build-linux]
|
#
|
||||||
runs-on: ubuntu-latest
|
# on:
|
||||||
steps:
|
# push:
|
||||||
- name: Checkout code
|
# branches:
|
||||||
uses: actions/checkout@v3
|
# - main
|
||||||
|
#
|
||||||
- name: Get version
|
# jobs:
|
||||||
id: get_version
|
# create-release:
|
||||||
run: |
|
# runs-on: ubuntu-latest
|
||||||
VERSION=$(cat version.txt)
|
# steps:
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
# - name: Checkout code
|
||||||
|
# uses: actions/checkout@v3
|
||||||
- name: Download all artifacts
|
#
|
||||||
uses: actions/download-artifact@v3
|
# - name: Get version
|
||||||
|
# id: get_version
|
||||||
- name: Attach executables to release
|
# run: |
|
||||||
uses: softprops/action-gh-release@v1
|
# VERSION=$(cat version.txt)
|
||||||
with:
|
# echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
tag_name: v${{ env.VERSION }}
|
#
|
||||||
files: |
|
# - name: Create Release
|
||||||
macropad-windows/macropad.exe
|
# id: create_release
|
||||||
macropad-linux/macropad
|
# uses: softprops/action-gh-release@v1
|
||||||
# macropad-macos/macropad.app/**/*
|
# 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
BIN
Macro Pad.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
301
README.md
301
README.md
@@ -1,164 +1,261 @@
|
|||||||
# MacroPad Server
|
# 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
|
## Features
|
||||||
|
|
||||||
- **Text Macros**: Insert frequently used text snippets with a single click
|
### Macro Capabilities
|
||||||
- **Application Macros**: Launch applications or scripts directly
|
- **Command Sequences**: Build multi-step macros with:
|
||||||
- **Key Modifiers**: Add Ctrl, Alt, Shift modifiers and Enter keypress to your text macros
|
- **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
|
- **Custom Images**: Assign images to macros for easy identification
|
||||||
- **Category Management**: Organize macros into custom tabs for better organization
|
- **Category Management**: Organize macros into custom tabs
|
||||||
- **Web Interface**: Access and trigger your macros from other devices on your network
|
|
||||||
- **System Tray Integration**: Minimize to tray when minimized, exit when closed
|
### Interfaces
|
||||||
- **QR Code Generation**: Quickly connect mobile devices to the web interface
|
- **Desktop GUI**: Modern PySide6/Qt interface with visual command builder
|
||||||
- **Sorting Options**: Sort macros by name, type, or recent usage
|
- **Web Interface**: PWA-enabled for installation on any device
|
||||||
- **Persistent Storage**: Macros are automatically saved for future sessions
|
- **System Tray**: Minimize to tray, always accessible
|
||||||
- **Dark Theme**: Modern dark interface for comfortable use
|
|
||||||
- **Modular Architecture**: Clean separation of concerns with dedicated modules
|
### 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
|
## Requirements
|
||||||
|
|
||||||
- Python 3.11+
|
- Python 3.11+
|
||||||
- Required Python packages (install via requirements.txt):
|
- Dependencies managed via `pyproject.toml`
|
||||||
- tkinter
|
|
||||||
- flask
|
### Core Dependencies
|
||||||
- pyautogui
|
- PySide6 (Desktop GUI)
|
||||||
- pystray
|
- FastAPI + Uvicorn (Web server)
|
||||||
- Pillow (PIL)
|
- PyAutoGUI (Keyboard automation)
|
||||||
- waitress
|
- Pillow (Image processing)
|
||||||
- netifaces
|
- pystray (System tray)
|
||||||
- qrcode
|
- netifaces (Network detection)
|
||||||
|
- qrcode (QR code generation)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Method 1: From Source
|
### Method 1: Using uv (Recommended)
|
||||||
|
|
||||||
1. Clone or download this repository
|
|
||||||
2. Install the required dependencies:
|
|
||||||
```bash
|
```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
|
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`
|
- Windows: `macropad.exe`
|
||||||
- Linux: `macropad`
|
- Linux: `macropad`
|
||||||
- macOS: `macropad.app`
|
- macOS: `MacroPad Server.app`
|
||||||
3. Run the downloaded file
|
3. Run the downloaded file
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!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
|
## 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
|
# If installed with pip
|
||||||
- **Edit Macro**: Modify existing macros
|
python main.py
|
||||||
- **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.
|
|
||||||
|
|
||||||
### Creating a Macro
|
### Creating a Macro
|
||||||
|
|
||||||
1. Click the "Add Macro" button
|
1. Click **+ Add Macro** in the toolbar
|
||||||
2. Fill in the details:
|
2. Enter a **Name** and optional **Category**
|
||||||
- **Name**: A descriptive name for your macro
|
3. Build your command sequence using the buttons:
|
||||||
- **Category**: Assign a category to associate with a tab
|
- **+ Text**: Add text to type
|
||||||
- **Type**: Choose between Text or Application
|
- **+ Key**: Add a key press (enter, tab, escape, etc.)
|
||||||
- **Command/Text**: The text to insert or application command to run
|
- **+ Hotkey**: Add a key combination (ctrl+c, alt+tab)
|
||||||
- **Modifiers**: Select any combination of Ctrl, Alt, Shift, and Enter
|
- **+ Wait**: Add a delay in milliseconds
|
||||||
- **Image**: Optionally add an image for visual identification
|
- **+ App**: Add an application to launch
|
||||||
3. Click "Save" to create your macro
|
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
|
[TEXT] myusername
|
||||||
3. Navigate to `http://<your-ip-address>:40000`
|
[WAIT] 200ms
|
||||||
4. Click on any macro to execute it on your main computer
|
[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
|
### System Tray
|
||||||
|
|
||||||
When minimized to the system tray:
|
- Minimize window → App continues in tray
|
||||||
- Right-click the tray icon to show options
|
- Right-click tray icon:
|
||||||
- Select "Show" to restore the window
|
- **Show**: Restore window
|
||||||
- Select "Exit" to close the application
|
- **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
|
## Example Application Commands
|
||||||
|
|
||||||
### Windows Examples
|
### Windows
|
||||||
|
```bash
|
||||||
#### Steam Applications
|
# Steam game
|
||||||
```
|
|
||||||
"C:\Program Files (x86)\Steam\steam.exe" steam://rungameid/2767030
|
"C:\Program Files (x86)\Steam\steam.exe" steam://rungameid/2767030
|
||||||
```
|
|
||||||
|
|
||||||
#### Chrome to a website
|
# Chrome to website
|
||||||
```
|
"C:\Program Files\Google\Chrome\Application\chrome.exe" https://example.com
|
||||||
"C:\Program Files\Google\Chrome\Application\chrome.exe" http://twitch.tv/shadowdao
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Run Notepad
|
# Open folder
|
||||||
```
|
|
||||||
notepad.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Open File Explorer to a specific location
|
|
||||||
```
|
|
||||||
explorer.exe "C:\Users\YourUsername\Documents"
|
explorer.exe "C:\Users\YourUsername\Documents"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Linux Examples
|
### Linux
|
||||||
|
```bash
|
||||||
#### Opening Firefox
|
# Firefox
|
||||||
```
|
|
||||||
firefox https://example.com
|
firefox https://example.com
|
||||||
```
|
|
||||||
|
|
||||||
#### Opening Steam
|
# Steam game
|
||||||
```
|
|
||||||
steam steam://rungameid/2767030
|
steam steam://rungameid/2767030
|
||||||
```
|
|
||||||
|
|
||||||
#### Launch Terminal
|
# Terminal
|
||||||
```
|
|
||||||
gnome-terminal
|
gnome-terminal
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Open File Manager
|
### macOS
|
||||||
```
|
```bash
|
||||||
nautilus ~/Documents
|
# Safari
|
||||||
```
|
|
||||||
|
|
||||||
### macOS Examples
|
|
||||||
|
|
||||||
#### Opening Safari
|
|
||||||
```
|
|
||||||
open -a Safari https://example.com
|
open -a Safari https://example.com
|
||||||
```
|
|
||||||
|
|
||||||
#### Opening Terminal
|
# VS Code
|
||||||
```
|
open -a "Visual Studio Code"
|
||||||
open -a Terminal
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Open Finder to a specific location
|
# Folder
|
||||||
```
|
|
||||||
open ~/Documents
|
open ~/Documents
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Launch Applications
|
## Building Executables
|
||||||
```
|
|
||||||
open -a "Visual Studio Code"
|
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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Configuration and constants for MacroPad Server
|
# Configuration and constants for MacroPad Server
|
||||||
|
|
||||||
VERSION = "0.8.5 Beta"
|
VERSION = "0.9.0"
|
||||||
DEFAULT_PORT = 40000
|
DEFAULT_PORT = 40000
|
||||||
|
|
||||||
# UI Theme colors
|
# UI Theme colors
|
||||||
|
|||||||
BIN
dist/macropad.exe
vendored
BIN
dist/macropad.exe
vendored
Binary file not shown.
7
gui/__init__.py
Normal file
7
gui/__init__.py
Normal 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
5
gui/command_builder.py
Normal 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
591
gui/macro_editor.py
Normal 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
509
gui/main_window.py
Normal 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
1
gui/widgets/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# MacroPad Server GUI Widgets
|
||||||
394
macro_manager.py
394
macro_manager.py
@@ -1,4 +1,4 @@
|
|||||||
# Macro management and execution
|
# Macro management and execution with command sequence support
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -7,238 +7,344 @@ import pyautogui
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
class MacroManager:
|
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.data_file = data_file
|
||||||
self.images_dir = images_dir
|
self.images_dir = images_dir
|
||||||
self.app_dir = app_dir
|
self.app_dir = app_dir
|
||||||
self.macros = {}
|
self.macros = {}
|
||||||
self.load_macros()
|
self.load_macros()
|
||||||
|
|
||||||
def load_macros(self):
|
def load_macros(self):
|
||||||
"""Load macros from JSON file"""
|
"""Load macros from JSON file and migrate if needed."""
|
||||||
try:
|
try:
|
||||||
if os.path.exists(self.data_file):
|
if os.path.exists(self.data_file):
|
||||||
with open(self.data_file, "r") as file:
|
with open(self.data_file, "r") as file:
|
||||||
self.macros = json.load(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:
|
except Exception as e:
|
||||||
print(f"Error loading macros: {e}")
|
print(f"Error loading macros: {e}")
|
||||||
self.macros = {}
|
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):
|
def save_macros(self):
|
||||||
"""Save macros to JSON file"""
|
"""Save macros to JSON file."""
|
||||||
try:
|
try:
|
||||||
with open(self.data_file, "w") as file:
|
with open(self.data_file, "w") as file:
|
||||||
json.dump(self.macros, file, indent=4)
|
json.dump(self.macros, file, indent=4)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving macros: {e}")
|
print(f"Error saving macros: {e}")
|
||||||
|
|
||||||
def get_sorted_macros(self, sort_by="name"):
|
def get_sorted_macros(self, sort_by: str = "name"):
|
||||||
"""Get macros sorted by specified criteria"""
|
"""Get macros sorted by specified criteria."""
|
||||||
macro_list = list(self.macros.items())
|
macro_list = list(self.macros.items())
|
||||||
|
|
||||||
if sort_by == "name":
|
if sort_by == "name":
|
||||||
macro_list.sort(key=lambda x: x[1]["name"].lower())
|
macro_list.sort(key=lambda x: x[1]["name"].lower())
|
||||||
elif sort_by == "type":
|
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":
|
elif sort_by == "recent":
|
||||||
# Sort by last_used timestamp if available, otherwise by name
|
macro_list.sort(
|
||||||
macro_list.sort(key=lambda x: (x[1].get("last_used", 0), x[1]["name"].lower()), reverse=True)
|
key=lambda x: (x[1].get("last_used", 0), x[1]["name"].lower()),
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
return macro_list
|
return macro_list
|
||||||
|
|
||||||
def filter_macros_by_tab(self, macro_list, tab_name):
|
def filter_macros_by_tab(self, macro_list: list, tab_name: str):
|
||||||
"""Filter macros based on tab name"""
|
"""Filter macros based on tab/category name."""
|
||||||
if tab_name == "All":
|
if tab_name == "All":
|
||||||
return macro_list
|
return macro_list
|
||||||
|
|
||||||
filtered = []
|
filtered = []
|
||||||
for macro_id, macro in macro_list:
|
for macro_id, macro in macro_list:
|
||||||
# Check type match
|
# Check category match
|
||||||
if macro.get("type", "").title() == tab_name:
|
if macro.get("category") == tab_name:
|
||||||
filtered.append((macro_id, macro))
|
filtered.append((macro_id, macro))
|
||||||
# Check custom category match
|
# Check first command type match
|
||||||
elif macro.get("category") == tab_name:
|
elif macro.get("commands"):
|
||||||
filtered.append((macro_id, macro))
|
first_type = macro["commands"][0].get("type", "").title()
|
||||||
|
if first_type == tab_name:
|
||||||
|
filtered.append((macro_id, macro))
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
def get_unique_tabs(self):
|
def get_unique_tabs(self) -> list:
|
||||||
"""Get list of unique tabs based on macro types and categories"""
|
"""Get list of unique tabs based on categories."""
|
||||||
tabs = ["All"]
|
tabs = ["All"]
|
||||||
unique_types = set()
|
categories = set()
|
||||||
|
|
||||||
for macro in self.macros.values():
|
for macro in self.macros.values():
|
||||||
if macro.get("type"):
|
|
||||||
unique_types.add(macro["type"].title())
|
|
||||||
if macro.get("category"):
|
if macro.get("category"):
|
||||||
unique_types.add(macro["category"])
|
categories.add(macro["category"])
|
||||||
|
|
||||||
for tab_type in sorted(unique_types):
|
for category in sorted(categories):
|
||||||
if tab_type not in ["All"]:
|
if category and category not in tabs:
|
||||||
tabs.append(tab_type)
|
tabs.append(category)
|
||||||
|
|
||||||
return tabs
|
return tabs
|
||||||
|
|
||||||
def add_macro(self, name, macro_type, command, category="", modifiers=None, image_path=""):
|
def add_macro(
|
||||||
"""Add a new macro"""
|
self,
|
||||||
if modifiers is None:
|
name: str,
|
||||||
modifiers = {"ctrl": False, "alt": False, "shift": False, "enter": False}
|
commands: list,
|
||||||
|
category: str = "",
|
||||||
|
image_path: str = ""
|
||||||
|
) -> str:
|
||||||
|
"""Add a new macro with command sequence."""
|
||||||
macro_id = str(uuid.uuid4())
|
macro_id = str(uuid.uuid4())
|
||||||
|
|
||||||
# Process image if provided
|
# Process image if provided
|
||||||
image_path_reference = ""
|
image_path_reference = self._process_image(image_path) if image_path else ""
|
||||||
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}")
|
|
||||||
|
|
||||||
# Create macro data
|
# Create macro data
|
||||||
macro_data = {
|
macro_data = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"type": macro_type,
|
"type": "sequence",
|
||||||
"command": command,
|
"commands": commands,
|
||||||
|
"category": category,
|
||||||
"image_path": image_path_reference,
|
"image_path": image_path_reference,
|
||||||
"modifiers": modifiers,
|
|
||||||
"last_used": 0
|
"last_used": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if category:
|
|
||||||
macro_data["category"] = category
|
|
||||||
|
|
||||||
self.macros[macro_id] = macro_data
|
self.macros[macro_id] = macro_data
|
||||||
self.save_macros()
|
self.save_macros()
|
||||||
return macro_id
|
return macro_id
|
||||||
|
|
||||||
def update_macro(self, macro_id, name, macro_type, command, category="", modifiers=None, image_path=""):
|
def update_macro(
|
||||||
"""Update an existing 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:
|
if macro_id not in self.macros:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if modifiers is None:
|
|
||||||
modifiers = {"ctrl": False, "alt": False, "shift": False, "enter": False}
|
|
||||||
|
|
||||||
macro = self.macros[macro_id]
|
macro = self.macros[macro_id]
|
||||||
|
|
||||||
# Keep the old image or update with new one
|
# Keep old image or update with new one
|
||||||
image_path_reference = macro.get("image_path", "")
|
if image_path is not None:
|
||||||
if image_path:
|
image_path_reference = self._process_image(image_path) if image_path else ""
|
||||||
try:
|
else:
|
||||||
# Generate unique filename for the image
|
image_path_reference = macro.get("image_path", "")
|
||||||
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}")
|
|
||||||
|
|
||||||
# Update macro data
|
# Update macro data
|
||||||
updated_macro = {
|
self.macros[macro_id] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"type": macro_type,
|
"type": "sequence",
|
||||||
"command": command,
|
"commands": commands,
|
||||||
|
"category": category,
|
||||||
"image_path": image_path_reference,
|
"image_path": image_path_reference,
|
||||||
"modifiers": modifiers,
|
|
||||||
"last_used": macro.get("last_used", 0)
|
"last_used": macro.get("last_used", 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if category:
|
|
||||||
updated_macro["category"] = category
|
|
||||||
|
|
||||||
self.macros[macro_id] = updated_macro
|
|
||||||
self.save_macros()
|
self.save_macros()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def delete_macro(self, macro_id):
|
def _process_image(self, image_path: str) -> str:
|
||||||
"""Delete a macro"""
|
"""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:
|
if macro_id not in self.macros:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
macro = self.macros[macro_id]
|
macro = self.macros[macro_id]
|
||||||
|
|
||||||
# Delete associated image file if it exists
|
# Delete associated image file
|
||||||
if "image_path" in macro and macro["image_path"]:
|
if macro.get("image_path"):
|
||||||
try:
|
try:
|
||||||
img_path = os.path.join(self.app_dir, macro["image_path"])
|
img_path = os.path.join(self.app_dir, macro["image_path"])
|
||||||
if os.path.exists(img_path):
|
if os.path.exists(img_path):
|
||||||
os.remove(img_path)
|
os.remove(img_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error removing image file: {e}")
|
print(f"Error removing image file: {e}")
|
||||||
|
|
||||||
del self.macros[macro_id]
|
del self.macros[macro_id]
|
||||||
self.save_macros()
|
self.save_macros()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def execute_macro(self, macro_id):
|
def execute_macro(self, macro_id: str) -> bool:
|
||||||
"""Execute a macro by ID"""
|
"""Execute a macro by ID."""
|
||||||
if macro_id not in self.macros:
|
if macro_id not in self.macros:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
macro = self.macros[macro_id]
|
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.macros[macro_id]["last_used"] = time.time()
|
||||||
self.save_macros()
|
self.save_macros()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if macro["type"] == "text":
|
commands = macro.get("commands", [])
|
||||||
# Handle key modifiers
|
for cmd in commands:
|
||||||
modifiers = macro.get("modifiers", {})
|
self._execute_command(cmd)
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error executing macro: {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()
|
||||||
|
|||||||
@@ -1,12 +1,53 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
# PyInstaller spec file for MacroPad Server (Windows)
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['main.py'],
|
['main.py'],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[],
|
datas=[
|
||||||
hiddenimports=[],
|
('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=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
@@ -14,6 +55,7 @@ a = Analysis(
|
|||||||
noarchive=False,
|
noarchive=False,
|
||||||
optimize=0,
|
optimize=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
pyz = PYZ(a.pure)
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
exe = EXE(
|
exe = EXE(
|
||||||
@@ -35,4 +77,5 @@ exe = EXE(
|
|||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
|
icon='Macro Pad.png',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
# PyInstaller spec file for MacroPad Server (Linux)
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
|
|
||||||
@@ -6,15 +9,54 @@ a = Analysis(
|
|||||||
['main.py'],
|
['main.py'],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[],
|
datas=[
|
||||||
|
('web', 'web'),
|
||||||
|
('Macro Pad.png', '.'),
|
||||||
|
],
|
||||||
hiddenimports=[
|
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',
|
'qrcode',
|
||||||
'PIL._tkinter_finder',
|
'PIL',
|
||||||
|
'pyautogui',
|
||||||
|
'pystray',
|
||||||
'pystray._base',
|
'pystray._base',
|
||||||
|
'netifaces',
|
||||||
|
'websockets',
|
||||||
|
'multipart',
|
||||||
|
# Linux system tray
|
||||||
'gi',
|
'gi',
|
||||||
'gi.repository.Gtk',
|
'gi.repository.Gtk',
|
||||||
'gi.repository.AppIndicator3',
|
'gi.repository.AppIndicator3',
|
||||||
'gi.repository.GdkPixbuf',
|
'gi.repository.GdkPixbuf',
|
||||||
|
# Packaging
|
||||||
'packaging.version',
|
'packaging.version',
|
||||||
'packaging.specifiers',
|
'packaging.specifiers',
|
||||||
'packaging.requirements',
|
'packaging.requirements',
|
||||||
@@ -28,7 +70,9 @@ a = Analysis(
|
|||||||
cipher=block_cipher,
|
cipher=block_cipher,
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
|
||||||
exe = EXE(
|
exe = EXE(
|
||||||
pyz,
|
pyz,
|
||||||
a.scripts,
|
a.scripts,
|
||||||
@@ -49,4 +93,4 @@ exe = EXE(
|
|||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,53 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
# PyInstaller spec file for MacroPad Server (macOS)
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['main.py'],
|
['main.py'],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[],
|
datas=[
|
||||||
hiddenimports=[],
|
('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=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
@@ -14,6 +55,7 @@ a = Analysis(
|
|||||||
noarchive=False,
|
noarchive=False,
|
||||||
optimize=0,
|
optimize=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
pyz = PYZ(a.pure)
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
exe = EXE(
|
exe = EXE(
|
||||||
@@ -39,7 +81,12 @@ exe = EXE(
|
|||||||
|
|
||||||
app = BUNDLE(
|
app = BUNDLE(
|
||||||
exe,
|
exe,
|
||||||
name='macropad.app',
|
name='MacroPad Server.app',
|
||||||
icon=None,
|
icon='Macro Pad.png',
|
||||||
bundle_identifier=None,
|
bundle_identifier='com.macropad.server',
|
||||||
)
|
info_plist={
|
||||||
|
'CFBundleShortVersionString': '0.9.0',
|
||||||
|
'CFBundleName': 'MacroPad Server',
|
||||||
|
'NSHighResolutionCapable': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
520
main.py
520
main.py
@@ -1,502 +1,52 @@
|
|||||||
# Main application file for MacroPad Server
|
# Main application file for MacroPad Server
|
||||||
|
# PySide6 version
|
||||||
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk, messagebox
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import multiprocessing
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class MacroPadServer:
|
def get_app_dir():
|
||||||
def __init__(self, root):
|
"""Get the application directory."""
|
||||||
self.root = root
|
if getattr(sys, 'frozen', False):
|
||||||
self.root.title("MacroPad Server")
|
# Running as compiled executable
|
||||||
self.root.geometry("800x600")
|
return os.path.dirname(sys.executable)
|
||||||
self.configure_styles()
|
else:
|
||||||
|
# Running as script
|
||||||
# Set up directories
|
return os.path.dirname(os.path.abspath(__file__))
|
||||||
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)
|
|
||||||
|
|
||||||
# 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
|
def main():
|
||||||
self.create_ui()
|
"""Main entry point."""
|
||||||
|
# Required for multiprocessing on Windows
|
||||||
# Set up window event handlers
|
multiprocessing.freeze_support()
|
||||||
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
|
|
||||||
self.root.bind('<Unmap>', self.on_minimize)
|
|
||||||
|
|
||||||
# Initialize tray icon
|
|
||||||
self.create_tray_icon()
|
|
||||||
|
|
||||||
def configure_styles(self):
|
# Get app directory
|
||||||
"""Configure the dark theme styles"""
|
app_dir = get_app_dir()
|
||||||
self.root.configure(bg=THEME['bg_color'])
|
|
||||||
|
|
||||||
style = ttk.Style()
|
# Import PySide6 after freeze_support
|
||||||
style.theme_use("clam")
|
from PySide6.QtWidgets import QApplication
|
||||||
style.configure("TButton", background=THEME['button_bg'], foreground=THEME['button_fg'])
|
from PySide6.QtCore import Qt
|
||||||
style.map("TButton", background=[("active", THEME['accent_color'])])
|
from PySide6.QtGui import QIcon
|
||||||
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
|
|
||||||
|
|
||||||
def create_ui(self):
|
# Create application
|
||||||
"""Create the main user interface"""
|
app = QApplication(sys.argv)
|
||||||
# Create main container
|
app.setApplicationName("MacroPad Server")
|
||||||
main_frame = ttk.Frame(self.root)
|
app.setOrganizationName("MacroPad")
|
||||||
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
||||||
|
|
||||||
# Left side: Macro list and controls
|
# Set application icon
|
||||||
left_frame = ttk.Frame(main_frame)
|
icon_path = os.path.join(app_dir, "Macro Pad.png")
|
||||||
left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
if os.path.exists(icon_path):
|
||||||
|
app.setWindowIcon(QIcon(icon_path))
|
||||||
|
|
||||||
# Sort controls
|
# Import and create main window
|
||||||
self._create_sort_controls(left_frame)
|
from gui.main_window import MainWindow
|
||||||
|
window = MainWindow(app_dir)
|
||||||
# Create notebook for tabs
|
window.show()
|
||||||
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)
|
|
||||||
|
|
||||||
# Button controls
|
# Run application
|
||||||
self._create_macro_buttons(left_frame)
|
sys.exit(app.exec())
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
root = tk.Tk()
|
main()
|
||||||
app = MacroPadServer(root)
|
|
||||||
root.mainloop()
|
|
||||||
|
|||||||
38
main.spec
38
main.spec
@@ -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
62
pyproject.toml
Normal 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"]
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
pillow
|
|
||||||
pyautogui
|
|
||||||
pystray
|
|
||||||
flask
|
|
||||||
waitress
|
|
||||||
netifaces
|
|
||||||
qrcode
|
|
||||||
283
ui_components.py
283
ui_components.py
@@ -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()
|
|
||||||
@@ -1 +1 @@
|
|||||||
0.8.5
|
0.9.0
|
||||||
|
|||||||
546
web/css/styles.css
Normal file
546
web/css/styles.css
Normal 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
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
BIN
web/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
91
web/index.html
Normal file
91
web/index.html
Normal 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()">×</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
481
web/js/app.js
Normal 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
25
web/manifest.json
Normal 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
72
web/service-worker.js
Normal 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('/');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
343
web_server.py
343
web_server.py
@@ -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
|
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:
|
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.macro_manager = macro_manager
|
||||||
self.app_dir = app_dir
|
self.app_dir = app_dir
|
||||||
self.port = port
|
self.port = port
|
||||||
self.app = None
|
self.app = None
|
||||||
|
self.manager = ConnectionManager()
|
||||||
def create_app(self):
|
self.server = None
|
||||||
"""Create and configure Flask application"""
|
|
||||||
app = Flask(__name__)
|
def create_app(self) -> FastAPI:
|
||||||
|
"""Create FastAPI application."""
|
||||||
# Disable Flask's logging except for errors
|
|
||||||
log = logging.getLogger('werkzeug')
|
@asynccontextmanager
|
||||||
log.setLevel(logging.ERROR)
|
async def lifespan(app: FastAPI):
|
||||||
|
yield
|
||||||
@app.route('/')
|
|
||||||
def index():
|
app = FastAPI(
|
||||||
return render_template_string(INDEX_HTML)
|
title="MacroPad Server",
|
||||||
|
version=VERSION,
|
||||||
@app.route('/api/tabs')
|
lifespan=lifespan
|
||||||
def get_tabs():
|
)
|
||||||
"""Get all available tabs (similar to setup_tabs logic)"""
|
|
||||||
tabs = ["All"]
|
# Serve static files from web directory
|
||||||
|
web_dir = os.path.join(self.app_dir, "web")
|
||||||
# Add tabs based on macro types and custom categories
|
if os.path.exists(web_dir):
|
||||||
unique_types = set()
|
app.mount("/static", StaticFiles(directory=web_dir), name="static")
|
||||||
for macro in self.macro_manager.macros.values():
|
|
||||||
if macro.get("type"):
|
# Serve macro images
|
||||||
unique_types.add(macro["type"].title())
|
images_dir = os.path.join(self.app_dir, "macro_images")
|
||||||
# Check for custom category
|
if os.path.exists(images_dir):
|
||||||
if macro.get("category"):
|
app.mount("/images", StaticFiles(directory=images_dir), name="images")
|
||||||
unique_types.add(macro["category"])
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
for tab_type in sorted(unique_types):
|
async def index():
|
||||||
if tab_type not in ["All"]:
|
"""Serve the main PWA page."""
|
||||||
tabs.append(tab_type)
|
index_path = os.path.join(web_dir, "index.html")
|
||||||
|
if os.path.exists(index_path):
|
||||||
return jsonify(tabs)
|
return FileResponse(index_path, media_type="text/html")
|
||||||
|
return HTMLResponse("<h1>MacroPad Server</h1><p>Web interface not found.</p>")
|
||||||
@app.route('/api/macros')
|
|
||||||
def get_macros():
|
@app.get("/manifest.json")
|
||||||
return jsonify(self.macro_manager.macros)
|
async def manifest():
|
||||||
|
"""Serve PWA manifest."""
|
||||||
@app.route('/api/macros/<tab_name>')
|
manifest_path = os.path.join(web_dir, "manifest.json")
|
||||||
def get_macros_by_tab(tab_name):
|
if os.path.exists(manifest_path):
|
||||||
"""Filter macros by tab (similar to filter_macros_by_tab logic)"""
|
return FileResponse(manifest_path, media_type="application/json")
|
||||||
if tab_name == "All":
|
raise HTTPException(status_code=404, detail="Manifest not found")
|
||||||
return jsonify(self.macro_manager.macros)
|
|
||||||
|
@app.get("/service-worker.js")
|
||||||
filtered_macros = {}
|
async def service_worker():
|
||||||
for macro_id, macro in self.macro_manager.macros.items():
|
"""Serve service worker."""
|
||||||
# Check type match
|
sw_path = os.path.join(web_dir, "service-worker.js")
|
||||||
if macro.get("type", "").title() == tab_name:
|
if os.path.exists(sw_path):
|
||||||
filtered_macros[macro_id] = macro
|
return FileResponse(sw_path, media_type="application/javascript")
|
||||||
# Check custom category match
|
raise HTTPException(status_code=404, detail="Service worker not found")
|
||||||
elif macro.get("category") == tab_name:
|
|
||||||
filtered_macros[macro_id] = macro
|
@app.get("/api/tabs")
|
||||||
|
async def get_tabs():
|
||||||
return jsonify(filtered_macros)
|
"""Get available tab categories."""
|
||||||
|
return {"tabs": self.macro_manager.get_unique_tabs()}
|
||||||
@app.route('/api/image/<path:image_path>')
|
|
||||||
def get_image(image_path):
|
@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:
|
try:
|
||||||
image_path = os.path.join(self.app_dir, image_path)
|
while True:
|
||||||
return send_file(image_path)
|
data = await websocket.receive_json()
|
||||||
except Exception as e:
|
# Handle incoming messages if needed
|
||||||
return str(e), 404
|
if data.get("type") == "ping":
|
||||||
|
await websocket.send_json({"type": "pong"})
|
||||||
@app.route('/api/execute', methods=['POST'])
|
except WebSocketDisconnect:
|
||||||
def execute_macro():
|
self.manager.disconnect(websocket)
|
||||||
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})
|
|
||||||
|
|
||||||
self.app = app
|
self.app = app
|
||||||
return app
|
return app
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Run the web server"""
|
"""Run the web server (blocking)."""
|
||||||
if not self.app:
|
if not self.app:
|
||||||
self.create_app()
|
self.create_app()
|
||||||
|
|
||||||
try:
|
config = uvicorn.Config(
|
||||||
serve(self.app, host='0.0.0.0', port=self.port, threads=4)
|
self.app,
|
||||||
except Exception as e:
|
host="0.0.0.0",
|
||||||
raise e
|
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()
|
||||||
|
|||||||
292
web_templates.py
292
web_templates.py
@@ -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>
|
|
||||||
'''
|
|
||||||
Reference in New Issue
Block a user