diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 08b3d61..58efbb9 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,157 +1,159 @@ -name: Build and Release - -on: - push: - branches: - - main - -jobs: - create-release: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Get version - id: get_version - run: | - VERSION=$(cat version.txt) - echo "VERSION=$VERSION" >> $GITHUB_ENV - - - name: Create Release - id: create_release - uses: softprops/action-gh-release@v1 - with: - tag_name: v${{ env.VERSION }} - name: Release v${{ env.VERSION }} - draft: false - prerelease: false - - build-windows: - needs: [create-release] - runs-on: windows-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: '16' - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pyinstaller - pip install -r requirements.txt - - - name: Build executable - run: | - pyinstaller macropad.spec - - - name: Upload Windows artifact - uses: actions/upload-artifact@v3 - with: - name: macropad-windows - path: dist/macropad.exe - - build-linux: - needs: [create-release] - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y python3-tk python3-dev - # Pystray requirements - sudo apt-get install -y libgtk-3-dev python3-gi python3-gi-cairo gir1.2-gtk-3.0 - # Direct dependencies for system tray functionality - sudo apt-get install -y gir1.2-appindicator3-0.1 - # Additional libraries - sudo apt-get install -y libcairo2-dev libgirepository1.0-dev - # For QR code display - sudo apt-get install -y python3-pil.imagetk - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pyinstaller - pip install -r requirements.txt - - name: Build executable - run: | - pyinstaller macropad_linux.spec - - - name: Upload Linux artifact - uses: actions/upload-artifact@v3 - with: - name: macropad-linux - path: dist/macropad - -# MacOS build is temporarily disabled -# Uncomment this section when macOS build environment becomes available +# ============================================================================= +# WORKFLOW DISABLED - Pending testing of v0.9.0 modernization +# ============================================================================= +# This workflow is temporarily disabled while testing the new: +# - PySide6 GUI (replacing Tkinter) +# - FastAPI web server (replacing Flask) +# - pyproject.toml build system (replacing requirements.txt) +# - PWA web interface # -# build-macos: -# needs: [create-release] -# runs-on: macos-latest -# steps: -# - name: Checkout code -# uses: actions/checkout@v3 -# -# - name: Set up Python -# uses: actions/setup-python@v4 -# with: -# python-version: '3.11' -# -# - name: Install dependencies -# run: | -# python -m pip install --upgrade pip -# pip install pyinstaller -# pip install -r requirements.txt -# -# - name: Build executable -# run: | -# pyinstaller macropad_macos.spec -# -# - name: Upload macOS artifact -# uses: actions/upload-artifact@v3 -# with: -# name: macropad-macos -# path: dist/macropad.app +# Uncomment the workflow below once builds are verified locally. +# ============================================================================= - attach-to-release: - needs: [create-release, build-windows, build-linux] - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Get version - id: get_version - run: | - VERSION=$(cat version.txt) - echo "VERSION=$VERSION" >> $GITHUB_ENV - - - name: Download all artifacts - uses: actions/download-artifact@v3 - - - name: Attach executables to release - uses: softprops/action-gh-release@v1 - with: - tag_name: v${{ env.VERSION }} - files: | - macropad-windows/macropad.exe - macropad-linux/macropad -# macropad-macos/macropad.app/**/* +# name: Build and Release +# +# on: +# push: +# branches: +# - main +# +# jobs: +# create-release: +# runs-on: ubuntu-latest +# steps: +# - name: Checkout code +# uses: actions/checkout@v3 +# +# - name: Get version +# id: get_version +# run: | +# VERSION=$(cat version.txt) +# echo "VERSION=$VERSION" >> $GITHUB_ENV +# +# - name: Create Release +# id: create_release +# uses: softprops/action-gh-release@v1 +# with: +# tag_name: v${{ env.VERSION }} +# name: Release v${{ env.VERSION }} +# draft: false +# prerelease: false +# +# build-windows: +# needs: [create-release] +# runs-on: windows-latest +# steps: +# - name: Checkout code +# uses: actions/checkout@v3 +# +# - name: Set up Python +# uses: actions/setup-python@v4 +# with: +# python-version: '3.11' +# +# - name: Install dependencies +# run: | +# python -m pip install --upgrade pip +# pip install pyinstaller +# pip install -e . +# +# - name: Build executable +# run: | +# pyinstaller macropad.spec +# +# - name: Upload Windows artifact +# uses: actions/upload-artifact@v3 +# with: +# name: macropad-windows +# path: dist/macropad.exe +# +# build-linux: +# needs: [create-release] +# runs-on: ubuntu-latest +# steps: +# - name: Checkout code +# uses: actions/checkout@v3 +# +# - name: Set up Python +# uses: actions/setup-python@v4 +# with: +# python-version: '3.11' +# +# - name: Install system dependencies +# run: | +# sudo apt-get update +# # PySide6 requirements +# sudo apt-get install -y libxcb-xinerama0 libxkbcommon-x11-0 libegl1 +# # System tray requirements +# sudo apt-get install -y libgtk-3-dev python3-gi python3-gi-cairo gir1.2-gtk-3.0 +# sudo apt-get install -y gir1.2-appindicator3-0.1 +# sudo apt-get install -y libcairo2-dev libgirepository1.0-dev +# +# - name: Install dependencies +# run: | +# python -m pip install --upgrade pip +# pip install pyinstaller +# pip install -e . +# +# - name: Build executable +# run: | +# pyinstaller macropad_linux.spec +# +# - name: Upload Linux artifact +# uses: actions/upload-artifact@v3 +# with: +# name: macropad-linux +# path: dist/macropad +# +# # MacOS build - requires macos runner +# # build-macos: +# # needs: [create-release] +# # runs-on: macos-latest +# # steps: +# # - name: Checkout code +# # uses: actions/checkout@v3 +# # +# # - name: Set up Python +# # uses: actions/setup-python@v4 +# # with: +# # python-version: '3.11' +# # +# # - name: Install dependencies +# # run: | +# # python -m pip install --upgrade pip +# # pip install pyinstaller +# # pip install -e . +# # +# # - name: Build executable +# # run: | +# # pyinstaller macropad_macos.spec +# # +# # - name: Upload macOS artifact +# # uses: actions/upload-artifact@v3 +# # with: +# # name: macropad-macos +# # path: dist/macropad.app +# +# attach-to-release: +# needs: [create-release, build-windows, build-linux] +# runs-on: ubuntu-latest +# steps: +# - name: Checkout code +# uses: actions/checkout@v3 +# +# - name: Get version +# id: get_version +# run: | +# VERSION=$(cat version.txt) +# echo "VERSION=$VERSION" >> $GITHUB_ENV +# +# - name: Download all artifacts +# uses: actions/download-artifact@v3 +# +# - name: Attach executables to release +# uses: softprops/action-gh-release@v1 +# with: +# tag_name: v${{ env.VERSION }} +# files: | +# macropad-windows/macropad.exe +# macropad-linux/macropad diff --git a/Macro Pad.png b/Macro Pad.png new file mode 100644 index 0000000..daa54a1 Binary files /dev/null and b/Macro Pad.png differ diff --git a/README.md b/README.md index 90b6f56..7890183 100644 --- a/README.md +++ b/README.md @@ -1,164 +1,261 @@ # MacroPad Server -A versatile MacroPad server application that lets you create, manage, and execute custom macros from both a local interface and remotely via a web interface. +A cross-platform macro management application with desktop and web interfaces. Create powerful command sequences with delays, key presses, and text input - accessible locally or remotely via a PWA-enabled web interface. ## Features -- **Text Macros**: Insert frequently used text snippets with a single click -- **Application Macros**: Launch applications or scripts directly -- **Key Modifiers**: Add Ctrl, Alt, Shift modifiers and Enter keypress to your text macros +### Macro Capabilities +- **Command Sequences**: Build multi-step macros with: + - **Text**: Type text strings + - **Keys**: Press individual keys (Enter, Tab, Escape, etc.) + - **Hotkeys**: Key combinations (Ctrl+C, Alt+Tab, etc.) + - **Wait**: Insert delays between commands (in milliseconds) + - **App**: Launch applications or scripts - **Custom Images**: Assign images to macros for easy identification -- **Category Management**: Organize macros into custom tabs for better organization -- **Web Interface**: Access and trigger your macros from other devices on your network -- **System Tray Integration**: Minimize to tray when minimized, exit when closed -- **QR Code Generation**: Quickly connect mobile devices to the web interface -- **Sorting Options**: Sort macros by name, type, or recent usage -- **Persistent Storage**: Macros are automatically saved for future sessions -- **Dark Theme**: Modern dark interface for comfortable use -- **Modular Architecture**: Clean separation of concerns with dedicated modules +- **Category Management**: Organize macros into custom tabs + +### Interfaces +- **Desktop GUI**: Modern PySide6/Qt interface with visual command builder +- **Web Interface**: PWA-enabled for installation on any device +- **System Tray**: Minimize to tray, always accessible + +### Additional Features +- **QR Code Generation**: Quickly connect mobile devices +- **Real-time Sync**: WebSocket updates across all connected devices +- **Offline Support**: PWA caches for offline macro viewing +- **Dark Theme**: Modern dark interface throughout +- **Auto-Migration**: Existing macros automatically upgraded to new format ## Requirements - Python 3.11+ -- Required Python packages (install via requirements.txt): - - tkinter - - flask - - pyautogui - - pystray - - Pillow (PIL) - - waitress - - netifaces - - qrcode +- Dependencies managed via `pyproject.toml` + +### Core Dependencies +- PySide6 (Desktop GUI) +- FastAPI + Uvicorn (Web server) +- PyAutoGUI (Keyboard automation) +- Pillow (Image processing) +- pystray (System tray) +- netifaces (Network detection) +- qrcode (QR code generation) ## Installation -### Method 1: From Source +### Method 1: Using uv (Recommended) -1. Clone or download this repository -2. Install the required dependencies: ```bash -pip install -r requirements.txt +# Install uv if not already installed +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Clone and install +git clone https://repo.anhonesthost.net/MacroPad/MP-Server.git +cd MP-Server +uv sync ``` -### Method 2: Pre-built Executables +### Method 2: Using pip + +```bash +git clone https://repo.anhonesthost.net/MacroPad/MP-Server.git +cd MP-Server +pip install -e . +``` + +### Method 3: Pre-built Executables 1. Go to the [Releases](https://repo.anhonesthost.net/MacroPad/MP-Server/releases) page -2. Download the appropriate version for your operating system: +2. Download for your operating system: - Windows: `macropad.exe` - Linux: `macropad` - - macOS: `macropad.app` + - macOS: `MacroPad Server.app` 3. Run the downloaded file > [!IMPORTANT] -> The executables are unsigned and may trigger security warnings. You may need to click "More info" and "Run anyway" in Windows SmartScreen, adjust permissions on Linux (`chmod +x macropad`), or override Gatekeeper on macOS. +> Executables are unsigned and may trigger security warnings. You may need to: +> - Windows: Click "More info" → "Run anyway" in SmartScreen +> - Linux: Run `chmod +x macropad` before executing +> - macOS: Right-click → Open, or adjust Gatekeeper settings ## Usage -### Main Interface +### Running the Application -When launched, MacroPad displays your existing macros with options to: +```bash +# If installed with uv +uv run python main.py -- **Add New Macro**: Create text snippets or application shortcuts -- **Edit Macro**: Modify existing macros -- **Delete Macro**: Remove unwanted macros -- **Sort Options**: Sort the Macros by type, name, and recent usage -- **Manage Tabs**: Assign categories to macros for better organization -- **Start Web Server**: Starts the web server to serve the MacroPad web interface. +# If installed with pip +python main.py +``` ### Creating a Macro -1. Click the "Add Macro" button -2. Fill in the details: - - **Name**: A descriptive name for your macro - - **Category**: Assign a category to associate with a tab - - **Type**: Choose between Text or Application - - **Command/Text**: The text to insert or application command to run - - **Modifiers**: Select any combination of Ctrl, Alt, Shift, and Enter - - **Image**: Optionally add an image for visual identification -3. Click "Save" to create your macro +1. Click **+ Add Macro** in the toolbar +2. Enter a **Name** and optional **Category** +3. Build your command sequence using the buttons: + - **+ Text**: Add text to type + - **+ Key**: Add a key press (enter, tab, escape, etc.) + - **+ Hotkey**: Add a key combination (ctrl+c, alt+tab) + - **+ Wait**: Add a delay in milliseconds + - **+ App**: Add an application to launch +4. Reorder commands with **Up/Down** buttons +5. Click **Save** -### Remote Access +### Example: Login Macro -The application runs a web server enabling remote access: +A macro that types a username, waits, presses Tab, types a password, and presses Enter: -1. Note your computer's local IP address (shown in the application header) -2. From another device on the same network, open a web browser -3. Navigate to `http://:40000` -4. Click on any macro to execute it on your main computer +``` +[TEXT] myusername +[WAIT] 200ms +[KEY] tab +[TEXT] mypassword +[KEY] enter +``` + +### Web Interface (PWA) + +1. Start the application (web server starts automatically on port 40000) +2. Note the URL shown in the toolbar (e.g., `http://192.168.1.100:40000`) +3. Open this URL on any device on your network +4. **Install as PWA**: + - Mobile: Tap browser menu → "Add to Home Screen" + - Desktop: Click install icon in address bar + +The web interface provides full macro management: +- View and execute macros +- Create and edit macros with command builder +- Organize into categories +- Real-time sync across devices ### System Tray -When minimized to the system tray: -- Right-click the tray icon to show options -- Select "Show" to restore the window -- Select "Exit" to close the application +- Minimize window → App continues in tray +- Right-click tray icon: + - **Show**: Restore window + - **Quit**: Exit application + +## Command Types Reference + +| Type | Description | Parameters | +|------|-------------|------------| +| `text` | Types a text string | `value`: The text to type | +| `key` | Presses a single key | `value`: Key name (enter, tab, escape, f1-f12, etc.) | +| `hotkey` | Presses key combination | `keys`: List of keys (e.g., ["ctrl", "c"]) | +| `wait` | Delays execution | `ms`: Milliseconds to wait | +| `app` | Launches application | `command`: Shell command to execute | ## Example Application Commands -### Windows Examples - -#### Steam Applications -``` +### Windows +```bash +# Steam game "C:\Program Files (x86)\Steam\steam.exe" steam://rungameid/2767030 -``` -#### Chrome to a website -``` -"C:\Program Files\Google\Chrome\Application\chrome.exe" http://twitch.tv/shadowdao -``` +# Chrome to website +"C:\Program Files\Google\Chrome\Application\chrome.exe" https://example.com -#### Run Notepad -``` -notepad.exe -``` - -#### Open File Explorer to a specific location -``` +# Open folder explorer.exe "C:\Users\YourUsername\Documents" ``` -### Linux Examples - -#### Opening Firefox -``` +### Linux +```bash +# Firefox firefox https://example.com -``` -#### Opening Steam -``` +# Steam game steam steam://rungameid/2767030 -``` -#### Launch Terminal -``` +# Terminal gnome-terminal ``` -#### Open File Manager -``` -nautilus ~/Documents -``` - -### macOS Examples - -#### Opening Safari -``` +### macOS +```bash +# Safari open -a Safari https://example.com -``` -#### Opening Terminal -``` -open -a Terminal -``` +# VS Code +open -a "Visual Studio Code" -#### Open Finder to a specific location -``` +# Folder open ~/Documents ``` -#### Launch Applications -``` -open -a "Visual Studio Code" +## Building Executables + +Build platform-specific executables using PyInstaller: + +```bash +# Install PyInstaller +pip install pyinstaller + +# Windows (run on Windows) +pyinstaller macropad.spec + +# Linux (run on Linux) +pyinstaller macropad_linux.spec + +# macOS (run on macOS) +pyinstaller macropad_macos.spec ``` -#### Special Thanks to CatArgent_ on Twitch for proof reading my stuff and providing valuable feedback. \ No newline at end of file +> [!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. diff --git a/config.py b/config.py index ac4a40d..7f908f5 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,6 @@ # Configuration and constants for MacroPad Server -VERSION = "0.8.5 Beta" +VERSION = "0.9.0" DEFAULT_PORT = 40000 # UI Theme colors diff --git a/dist/macropad.exe b/dist/macropad.exe deleted file mode 100644 index 20db5ca..0000000 Binary files a/dist/macropad.exe and /dev/null differ diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..8e8ab72 --- /dev/null +++ b/gui/__init__.py @@ -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'] diff --git a/gui/command_builder.py b/gui/command_builder.py new file mode 100644 index 0000000..3fab8f4 --- /dev/null +++ b/gui/command_builder.py @@ -0,0 +1,5 @@ +# Command builder widget (re-exported from macro_editor for convenience) + +from .macro_editor import CommandBuilder, CommandItem + +__all__ = ['CommandBuilder', 'CommandItem'] diff --git a/gui/macro_editor.py b/gui/macro_editor.py new file mode 100644 index 0000000..c1274c7 --- /dev/null +++ b/gui/macro_editor.py @@ -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() diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..f0afbf5 --- /dev/null +++ b/gui/main_window.py @@ -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() diff --git a/gui/widgets/__init__.py b/gui/widgets/__init__.py new file mode 100644 index 0000000..a859e64 --- /dev/null +++ b/gui/widgets/__init__.py @@ -0,0 +1 @@ +# MacroPad Server GUI Widgets diff --git a/macro_manager.py b/macro_manager.py index 038cf03..b2ee1d7 100644 --- a/macro_manager.py +++ b/macro_manager.py @@ -1,4 +1,4 @@ -# Macro management and execution +# Macro management and execution with command sequence support import json import os @@ -7,238 +7,344 @@ import pyautogui import subprocess import time from PIL import Image +from typing import Optional class MacroManager: - def __init__(self, data_file, images_dir, app_dir): + """Manages macro storage, migration, and execution with command sequences.""" + + def __init__(self, data_file: str, images_dir: str, app_dir: str): self.data_file = data_file self.images_dir = images_dir self.app_dir = app_dir self.macros = {} self.load_macros() - + def load_macros(self): - """Load macros from JSON file""" + """Load macros from JSON file and migrate if needed.""" try: if os.path.exists(self.data_file): with open(self.data_file, "r") as file: self.macros = json.load(file) + + # Migrate old format macros + migrated = False + for macro_id, macro in list(self.macros.items()): + if macro.get("type") != "sequence": + self.macros[macro_id] = self._migrate_macro(macro) + migrated = True + + if migrated: + self.save_macros() + print("Migrated macros to new command sequence format") + except Exception as e: print(f"Error loading macros: {e}") self.macros = {} + def _migrate_macro(self, old_macro: dict) -> dict: + """Convert old macro format to new command sequence format.""" + if old_macro.get("type") == "sequence": + return old_macro + + commands = [] + modifiers = old_macro.get("modifiers", {}) + + if old_macro.get("type") == "text": + # Build held keys list + held_keys = [] + for mod in ["ctrl", "alt", "shift"]: + if modifiers.get(mod): + held_keys.append(mod) + + if held_keys: + # Use hotkey for modified text + commands.append({ + "type": "hotkey", + "keys": held_keys + [old_macro.get("command", "")] + }) + else: + # Plain text + commands.append({ + "type": "text", + "value": old_macro.get("command", "") + }) + + # Add enter if requested + if modifiers.get("enter"): + commands.append({"type": "key", "value": "enter"}) + + elif old_macro.get("type") == "app": + commands.append({ + "type": "app", + "command": old_macro.get("command", "") + }) + + return { + "name": old_macro.get("name", "Unnamed"), + "type": "sequence", + "commands": commands, + "category": old_macro.get("category", ""), + "image_path": old_macro.get("image_path", ""), + "last_used": old_macro.get("last_used", 0) + } + def save_macros(self): - """Save macros to JSON file""" + """Save macros to JSON file.""" try: with open(self.data_file, "w") as file: json.dump(self.macros, file, indent=4) except Exception as e: print(f"Error saving macros: {e}") - def get_sorted_macros(self, sort_by="name"): - """Get macros sorted by specified criteria""" + def get_sorted_macros(self, sort_by: str = "name"): + """Get macros sorted by specified criteria.""" macro_list = list(self.macros.items()) - + if sort_by == "name": macro_list.sort(key=lambda x: x[1]["name"].lower()) elif sort_by == "type": - macro_list.sort(key=lambda x: (x[1].get("type", ""), x[1]["name"].lower())) + # Sort by first command type in sequence + def get_first_type(macro): + cmds = macro.get("commands", []) + return cmds[0].get("type", "") if cmds else "" + macro_list.sort(key=lambda x: (get_first_type(x[1]), x[1]["name"].lower())) elif sort_by == "recent": - # Sort by last_used timestamp if available, otherwise by name - macro_list.sort(key=lambda x: (x[1].get("last_used", 0), x[1]["name"].lower()), reverse=True) - + macro_list.sort( + key=lambda x: (x[1].get("last_used", 0), x[1]["name"].lower()), + reverse=True + ) + return macro_list - def filter_macros_by_tab(self, macro_list, tab_name): - """Filter macros based on tab name""" + def filter_macros_by_tab(self, macro_list: list, tab_name: str): + """Filter macros based on tab/category name.""" if tab_name == "All": return macro_list - + filtered = [] for macro_id, macro in macro_list: - # Check type match - if macro.get("type", "").title() == tab_name: + # Check category match + if macro.get("category") == tab_name: filtered.append((macro_id, macro)) - # Check custom category match - elif macro.get("category") == tab_name: - filtered.append((macro_id, macro)) - + # Check first command type match + elif macro.get("commands"): + first_type = macro["commands"][0].get("type", "").title() + if first_type == tab_name: + filtered.append((macro_id, macro)) + return filtered - def get_unique_tabs(self): - """Get list of unique tabs based on macro types and categories""" + def get_unique_tabs(self) -> list: + """Get list of unique tabs based on categories.""" tabs = ["All"] - unique_types = set() - + categories = set() + for macro in self.macros.values(): - if macro.get("type"): - unique_types.add(macro["type"].title()) if macro.get("category"): - unique_types.add(macro["category"]) - - for tab_type in sorted(unique_types): - if tab_type not in ["All"]: - tabs.append(tab_type) - + categories.add(macro["category"]) + + for category in sorted(categories): + if category and category not in tabs: + tabs.append(category) + return tabs - def add_macro(self, name, macro_type, command, category="", modifiers=None, image_path=""): - """Add a new macro""" - if modifiers is None: - modifiers = {"ctrl": False, "alt": False, "shift": False, "enter": False} - + def add_macro( + self, + name: str, + commands: list, + category: str = "", + image_path: str = "" + ) -> str: + """Add a new macro with command sequence.""" macro_id = str(uuid.uuid4()) - + # Process image if provided - image_path_reference = "" - if image_path: - try: - # Generate unique filename for the image - file_ext = os.path.splitext(image_path)[1].lower() - unique_filename = f"{uuid.uuid4().hex}{file_ext}" - dest_path = os.path.join(self.images_dir, unique_filename) - - # Resize image to max 256x256 - with Image.open(image_path) as img: - img.thumbnail((256, 256)) - img.save(dest_path) - - # Store the relative path to the image - image_path_reference = os.path.join("macro_images", unique_filename) - except Exception as e: - print(f"Error processing image: {e}") + image_path_reference = self._process_image(image_path) if image_path else "" # Create macro data macro_data = { "name": name, - "type": macro_type, - "command": command, + "type": "sequence", + "commands": commands, + "category": category, "image_path": image_path_reference, - "modifiers": modifiers, "last_used": 0 } - - if category: - macro_data["category"] = category self.macros[macro_id] = macro_data self.save_macros() return macro_id - def update_macro(self, macro_id, name, macro_type, command, category="", modifiers=None, image_path=""): - """Update an existing macro""" + def update_macro( + self, + macro_id: str, + name: str, + commands: list, + category: str = "", + image_path: Optional[str] = None + ) -> bool: + """Update an existing macro.""" if macro_id not in self.macros: return False - - if modifiers is None: - modifiers = {"ctrl": False, "alt": False, "shift": False, "enter": False} - + macro = self.macros[macro_id] - - # Keep the old image or update with new one - image_path_reference = macro.get("image_path", "") - if image_path: - try: - # Generate unique filename for the image - file_ext = os.path.splitext(image_path)[1].lower() - unique_filename = f"{uuid.uuid4().hex}{file_ext}" - dest_path = os.path.join(self.images_dir, unique_filename) - - # Resize image to max 256x256 - with Image.open(image_path) as img: - img.thumbnail((256, 256)) - img.save(dest_path) - - # Store the relative path to the image - image_path_reference = os.path.join("macro_images", unique_filename) - except Exception as e: - print(f"Error processing image: {e}") + + # Keep old image or update with new one + if image_path is not None: + image_path_reference = self._process_image(image_path) if image_path else "" + else: + image_path_reference = macro.get("image_path", "") # Update macro data - updated_macro = { + self.macros[macro_id] = { "name": name, - "type": macro_type, - "command": command, + "type": "sequence", + "commands": commands, + "category": category, "image_path": image_path_reference, - "modifiers": modifiers, "last_used": macro.get("last_used", 0) } - - if category: - updated_macro["category"] = category - - self.macros[macro_id] = updated_macro + self.save_macros() return True - def delete_macro(self, macro_id): - """Delete a macro""" + def _process_image(self, image_path: str) -> str: + """Process and store an image for a macro.""" + if not image_path: + return "" + + try: + file_ext = os.path.splitext(image_path)[1].lower() + unique_filename = f"{uuid.uuid4().hex}{file_ext}" + dest_path = os.path.join(self.images_dir, unique_filename) + + # Resize image to max 256x256 + with Image.open(image_path) as img: + img.thumbnail((256, 256)) + img.save(dest_path) + + return os.path.join("macro_images", unique_filename) + except Exception as e: + print(f"Error processing image: {e}") + return "" + + def delete_macro(self, macro_id: str) -> bool: + """Delete a macro.""" if macro_id not in self.macros: return False - + macro = self.macros[macro_id] - - # Delete associated image file if it exists - if "image_path" in macro and macro["image_path"]: + + # Delete associated image file + if macro.get("image_path"): try: img_path = os.path.join(self.app_dir, macro["image_path"]) if os.path.exists(img_path): os.remove(img_path) except Exception as e: print(f"Error removing image file: {e}") - + del self.macros[macro_id] self.save_macros() return True - def execute_macro(self, macro_id): - """Execute a macro by ID""" + def execute_macro(self, macro_id: str) -> bool: + """Execute a macro by ID.""" if macro_id not in self.macros: return False macro = self.macros[macro_id] - - # Update last_used timestamp for recent sorting + + # Update last_used timestamp self.macros[macro_id]["last_used"] = time.time() self.save_macros() - + try: - if macro["type"] == "text": - # Handle key modifiers - modifiers = macro.get("modifiers", {}) - - # Add modifier keys if enabled - if modifiers.get("ctrl", False): - pyautogui.keyDown('ctrl') - if modifiers.get("alt", False): - pyautogui.keyDown('alt') - if modifiers.get("shift", False): - pyautogui.keyDown('shift') - - # Handle single character vs multi-character commands - if str(macro["command"]) and len(str(macro["command"])) == 1: - pyautogui.keyDown(macro["command"]) - time.sleep(0.5) - pyautogui.keyUp(macro["command"]) - else: - pyautogui.typewrite(macro["command"], interval=0.02) - - # Release modifier keys in reverse order - if modifiers.get("shift", False): - pyautogui.keyUp('shift') - if modifiers.get("alt", False): - pyautogui.keyUp('alt') - if modifiers.get("ctrl", False): - pyautogui.keyUp('ctrl') - - # Add Enter/Return if requested - if modifiers.get("enter", False): - pyautogui.press('enter') - - elif macro["type"] == "app": - subprocess.Popen(macro["command"], shell=True) - + commands = macro.get("commands", []) + for cmd in commands: + self._execute_command(cmd) return True except Exception as e: print(f"Error executing macro: {e}") - return False \ No newline at end of file + 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() diff --git a/macropad.spec b/macropad.spec index ac5f158..0171001 100644 --- a/macropad.spec +++ b/macropad.spec @@ -1,12 +1,53 @@ # -*- mode: python ; coding: utf-8 -*- +# PyInstaller spec file for MacroPad Server (Windows) +import os a = Analysis( ['main.py'], pathex=[], binaries=[], - datas=[], - hiddenimports=[], + datas=[ + ('web', 'web'), + ('Macro Pad.png', '.'), + ], + hiddenimports=[ + # PySide6 + 'PySide6.QtCore', + 'PySide6.QtGui', + 'PySide6.QtWidgets', + # FastAPI and web server + 'fastapi', + 'uvicorn', + 'uvicorn.logging', + 'uvicorn.loops', + 'uvicorn.loops.auto', + 'uvicorn.protocols', + 'uvicorn.protocols.http', + 'uvicorn.protocols.http.auto', + 'uvicorn.protocols.websockets', + 'uvicorn.protocols.websockets.auto', + 'uvicorn.lifespan', + 'uvicorn.lifespan.on', + 'starlette', + 'starlette.routing', + 'starlette.responses', + 'starlette.staticfiles', + 'starlette.websockets', + 'anyio', + 'anyio._backends._asyncio', + # Pydantic + 'pydantic', + 'pydantic.fields', + # Other dependencies + 'qrcode', + 'PIL', + 'pyautogui', + 'pystray', + 'netifaces', + 'websockets', + 'multipart', + ], hookspath=[], hooksconfig={}, runtime_hooks=[], @@ -14,6 +55,7 @@ a = Analysis( noarchive=False, optimize=0, ) + pyz = PYZ(a.pure) exe = EXE( @@ -35,4 +77,5 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, + icon='Macro Pad.png', ) diff --git a/macropad_linux.spec b/macropad_linux.spec index f6f28d8..aa576ac 100644 --- a/macropad_linux.spec +++ b/macropad_linux.spec @@ -1,4 +1,7 @@ # -*- mode: python ; coding: utf-8 -*- +# PyInstaller spec file for MacroPad Server (Linux) + +import os block_cipher = None @@ -6,15 +9,54 @@ a = Analysis( ['main.py'], pathex=[], binaries=[], - datas=[], + datas=[ + ('web', 'web'), + ('Macro Pad.png', '.'), + ], hiddenimports=[ + # PySide6 + 'PySide6.QtCore', + 'PySide6.QtGui', + 'PySide6.QtWidgets', + 'PySide6.QtDBus', + # FastAPI and web server + 'fastapi', + 'uvicorn', + 'uvicorn.logging', + 'uvicorn.loops', + 'uvicorn.loops.auto', + 'uvicorn.protocols', + 'uvicorn.protocols.http', + 'uvicorn.protocols.http.auto', + 'uvicorn.protocols.websockets', + 'uvicorn.protocols.websockets.auto', + 'uvicorn.lifespan', + 'uvicorn.lifespan.on', + 'starlette', + 'starlette.routing', + 'starlette.responses', + 'starlette.staticfiles', + 'starlette.websockets', + 'anyio', + 'anyio._backends._asyncio', + # Pydantic + 'pydantic', + 'pydantic.fields', + # Other dependencies 'qrcode', - 'PIL._tkinter_finder', + 'PIL', + 'pyautogui', + 'pystray', 'pystray._base', + 'netifaces', + 'websockets', + 'multipart', + # Linux system tray 'gi', 'gi.repository.Gtk', 'gi.repository.AppIndicator3', 'gi.repository.GdkPixbuf', + # Packaging 'packaging.version', 'packaging.specifiers', 'packaging.requirements', @@ -28,7 +70,9 @@ a = Analysis( cipher=block_cipher, noarchive=False, ) + pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + exe = EXE( pyz, a.scripts, @@ -49,4 +93,4 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, -) \ No newline at end of file +) diff --git a/macropad_macos.spec b/macropad_macos.spec index 7c589df..3e7f58a 100644 --- a/macropad_macos.spec +++ b/macropad_macos.spec @@ -1,12 +1,53 @@ # -*- mode: python ; coding: utf-8 -*- +# PyInstaller spec file for MacroPad Server (macOS) +import os a = Analysis( ['main.py'], pathex=[], binaries=[], - datas=[], - hiddenimports=[], + datas=[ + ('web', 'web'), + ('Macro Pad.png', '.'), + ], + hiddenimports=[ + # PySide6 + 'PySide6.QtCore', + 'PySide6.QtGui', + 'PySide6.QtWidgets', + # FastAPI and web server + 'fastapi', + 'uvicorn', + 'uvicorn.logging', + 'uvicorn.loops', + 'uvicorn.loops.auto', + 'uvicorn.protocols', + 'uvicorn.protocols.http', + 'uvicorn.protocols.http.auto', + 'uvicorn.protocols.websockets', + 'uvicorn.protocols.websockets.auto', + 'uvicorn.lifespan', + 'uvicorn.lifespan.on', + 'starlette', + 'starlette.routing', + 'starlette.responses', + 'starlette.staticfiles', + 'starlette.websockets', + 'anyio', + 'anyio._backends._asyncio', + # Pydantic + 'pydantic', + 'pydantic.fields', + # Other dependencies + 'qrcode', + 'PIL', + 'pyautogui', + 'pystray', + 'netifaces', + 'websockets', + 'multipart', + ], hookspath=[], hooksconfig={}, runtime_hooks=[], @@ -14,6 +55,7 @@ a = Analysis( noarchive=False, optimize=0, ) + pyz = PYZ(a.pure) exe = EXE( @@ -39,7 +81,12 @@ exe = EXE( app = BUNDLE( exe, - name='macropad.app', - icon=None, - bundle_identifier=None, -) \ No newline at end of file + name='MacroPad Server.app', + icon='Macro Pad.png', + bundle_identifier='com.macropad.server', + info_plist={ + 'CFBundleShortVersionString': '0.9.0', + 'CFBundleName': 'MacroPad Server', + 'NSHighResolutionCapable': True, + }, +) diff --git a/main.py b/main.py index bee99a2..9b85c38 100644 --- a/main.py +++ b/main.py @@ -1,502 +1,52 @@ # Main application file for MacroPad Server +# PySide6 version -import tkinter as tk -from tkinter import ttk, messagebox import os import sys -import threading -import socket -import qrcode -import webbrowser -import pystray -from PIL import Image, ImageTk, ImageDraw, ImageFont - -from config import VERSION, DEFAULT_PORT, THEME -from macro_manager import MacroManager -from web_server import WebServer -from ui_components import MacroDialog, MacroSelector, TabManager +import multiprocessing -class MacroPadServer: - def __init__(self, root): - self.root = root - self.root.title("MacroPad Server") - self.root.geometry("800x600") - self.configure_styles() - - # Set up directories - if getattr(sys, 'frozen', False): - base_dir = os.path.dirname(sys.executable) - else: - base_dir = os.path.dirname(os.path.abspath(__file__)) - - data_file = os.path.join(base_dir, "macros.json") - images_dir = os.path.join(base_dir, "macro_images") - os.makedirs(images_dir, exist_ok=True) +def get_app_dir(): + """Get the application directory.""" + if getattr(sys, 'frozen', False): + # Running as compiled executable + return os.path.dirname(sys.executable) + else: + # Running as script + return os.path.dirname(os.path.abspath(__file__)) - # Initialize components - self.macro_manager = MacroManager(data_file, images_dir, base_dir) - self.web_server = WebServer(self.macro_manager, base_dir, DEFAULT_PORT) - - # UI state - self.current_sort = "name" - self.current_tab = "All" - self.image_cache = {} - - # Server state - self.server_running = False - self.flask_thread = None - - # Tray state - self.tray_icon = None - self.is_closing = False - # Create UI - self.create_ui() - - # Set up window event handlers - self.root.protocol("WM_DELETE_WINDOW", self.on_closing) - self.root.bind('', self.on_minimize) - - # Initialize tray icon - self.create_tray_icon() +def main(): + """Main entry point.""" + # Required for multiprocessing on Windows + multiprocessing.freeze_support() - def configure_styles(self): - """Configure the dark theme styles""" - self.root.configure(bg=THEME['bg_color']) + # Get app directory + app_dir = get_app_dir() - style = ttk.Style() - style.theme_use("clam") - style.configure("TButton", background=THEME['button_bg'], foreground=THEME['button_fg']) - style.map("TButton", background=[("active", THEME['accent_color'])]) - style.configure("TFrame", background=THEME['bg_color']) - style.configure("TLabel", background=THEME['bg_color'], foreground=THEME['fg_color']) - - # Configure notebook (tabs) style - style.configure("TNotebook", background=THEME['bg_color'], borderwidth=0) - style.configure("TNotebook.Tab", background=THEME['tab_bg'], foreground=THEME['fg_color'], - padding=[12, 8], borderwidth=0) - style.map("TNotebook.Tab", - background=[("selected", THEME['tab_selected'])], - foreground=[("selected", THEME['fg_color'])], - padding=[("selected", [12, 8])]) # Keep same padding when selected + # Import PySide6 after freeze_support + from PySide6.QtWidgets import QApplication + from PySide6.QtCore import Qt + from PySide6.QtGui import QIcon - def create_ui(self): - """Create the main user interface""" - # Create main container - main_frame = ttk.Frame(self.root) - main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + # Create application + app = QApplication(sys.argv) + app.setApplicationName("MacroPad Server") + app.setOrganizationName("MacroPad") - # Left side: Macro list and controls - left_frame = ttk.Frame(main_frame) - left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + # Set application icon + icon_path = os.path.join(app_dir, "Macro Pad.png") + if os.path.exists(icon_path): + app.setWindowIcon(QIcon(icon_path)) - # Sort controls - self._create_sort_controls(left_frame) - - # Create notebook for tabs - self.notebook = ttk.Notebook(left_frame) - self.notebook.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) - self.notebook.bind("<>", self.on_tab_change) + # Import and create main window + from gui.main_window import MainWindow + window = MainWindow(app_dir) + window.show() - # Button controls - self._create_macro_buttons(left_frame) - - # Right side: Server controls - right_frame = ttk.Frame(main_frame) - right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(10, 0)) - - self._create_server_controls(right_frame) - - # Version label - version_label = tk.Label(self.root, text=VERSION, - bg=THEME['bg_color'], fg=THEME['fg_color'], - font=('Helvetica', 8)) - version_label.pack(side=tk.BOTTOM, anchor=tk.SE, padx=5, pady=(0, 2)) - - # Initialize display - self.setup_tabs() - self.display_macros() - - def _create_sort_controls(self, parent): - """Create sorting controls""" - sort_frame = ttk.Frame(parent) - sort_frame.pack(fill=tk.X, pady=(0, 10)) - - ttk.Label(sort_frame, text="Sort by:").pack(side=tk.LEFT, padx=(0, 5)) - self.sort_var = tk.StringVar(value=self.current_sort) - sort_combo = ttk.Combobox(sort_frame, textvariable=self.sort_var, - values=["name", "type", "recent"], - state="readonly", width=10) - sort_combo.pack(side=tk.LEFT, padx=(0, 10)) - sort_combo.bind("<>", 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( - "", - lambda e: canvas.configure(scrollregion=canvas.bbox("all")) - ) - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - # Get sorted and filtered macros - sorted_macros = self.macro_manager.get_sorted_macros(self.current_sort) - filtered_macros = self.macro_manager.filter_macros_by_tab(sorted_macros, self.current_tab) - - # Display macros - for macro_id, macro in filtered_macros: - self._create_macro_button(scrollable_frame, macro_id, macro) - - # Display message if no macros - if not filtered_macros: - label = tk.Label(scrollable_frame, text="No macros in this category", - bg=THEME['bg_color'], fg=THEME['fg_color']) - label.pack(pady=20) - - def _create_macro_button(self, parent, macro_id, macro): - """Create a button for a single macro""" - frame = ttk.Frame(parent) - frame.pack(fill="x", pady=5, padx=5) - - button = tk.Button( - frame, text=macro["name"], - bg=THEME['button_bg'], fg=THEME['button_fg'], - activebackground=THEME['accent_color'], activeforeground=THEME['button_fg'], - relief=tk.RAISED, bd=2, pady=8, - command=lambda: self.macro_manager.execute_macro(macro_id) - ) - - # Add image if available - if "image_path" in macro and macro["image_path"]: - try: - if macro["image_path"] in self.image_cache: - button_image = self.image_cache[macro["image_path"]] - else: - img_path = os.path.join(self.macro_manager.app_dir, macro["image_path"]) - img = Image.open(img_path) - img = img.resize((32, 32)) - button_image = ImageTk.PhotoImage(img) - self.image_cache[macro["image_path"]] = button_image - - button.config(image=button_image, compound=tk.LEFT) - button.image = button_image # Keep reference - except Exception as e: - print(f"Error loading image for {macro['name']}: {e}") - - button.pack(side=tk.LEFT, fill=tk.X, expand=True) - - def manage_tabs(self): - """Open tab management dialog""" - tab_manager = TabManager(self.root, self.macro_manager) - tab_manager.show() - self.setup_tabs() - self.display_macros() - - def add_macro(self): - """Add a new macro""" - dialog = MacroDialog(self.root, self.macro_manager) - result = dialog.show() - if result: - self.setup_tabs() - self.display_macros() - - def edit_macro(self): - """Edit an existing macro""" - selector = MacroSelector(self.root, self.macro_manager, "Select Macro to Edit") - macro_id = selector.show() - if macro_id: - dialog = MacroDialog(self.root, self.macro_manager, macro_id) - result = dialog.show() - if result: - self.setup_tabs() - self.display_macros() - - def delete_macro(self): - """Delete a macro""" - selector = MacroSelector(self.root, self.macro_manager, "Select Macro to Delete") - macro_id = selector.show() - if macro_id: - macro_name = self.macro_manager.macros[macro_id]["name"] - if messagebox.askyesno("Confirm Deletion", f"Are you sure you want to delete macro '{macro_name}'?"): - self.macro_manager.delete_macro(macro_id) - self.setup_tabs() - self.display_macros() - - def toggle_server(self): - """Toggle web server on/off""" - if self.server_running: - self.stop_server() - else: - self.start_server() - - def start_server(self): - """Start the web server""" - try: - if not self.server_running: - self.server_running = True - self.flask_thread = threading.Thread(target=self.run_web_server) - self.flask_thread.daemon = True - self.flask_thread.start() - self.server_button.config(text="Stop Web Server") - - # Get IP address and display info - ip_address = self.get_ip_address() - if ip_address: - url = f"http://{ip_address}:{DEFAULT_PORT}" - url_text = f"Web UI available at:\n{url}" - self.url_var.set(url_text) - self.browser_button.config(state=tk.NORMAL) - self.generate_qr_code(url) - else: - self.url_var.set("No network interfaces found") - except Exception as e: - self.status_var.set(f"Error starting server: {e}") - self.server_running = False - - def stop_server(self): - """Stop the web server""" - if self.server_running: - self.server_running = False - self.status_var.set("Web server stopped") - self.server_button.config(text="Start Web Server") - self.url_var.set("") - self.browser_button.config(state=tk.DISABLED) - self.qr_label.config(image="") - - def run_web_server(self): - """Run the web server in a separate thread""" - self.status_var.set(f"Web server running on port {DEFAULT_PORT}") - try: - self.web_server.run() - except Exception as e: - self.status_var.set(f"Web server error: {e}") - self.server_running = False - - def get_ip_address(self): - """Get the primary internal IPv4 address""" - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - ip = s.getsockname()[0] - s.close() - return ip - except Exception as e: - print(f"Error getting IP address: {e}") - try: - hostname = socket.gethostname() - ip = socket.gethostbyname(hostname) - if not ip.startswith("127."): - return ip - - for addr_info in socket.getaddrinfo(hostname, None): - potential_ip = addr_info[4][0] - if '.' in potential_ip and not potential_ip.startswith("127."): - return potential_ip - except: - pass - return "127.0.0.1" - - def generate_qr_code(self, url): - """Generate and display QR code for the URL""" - try: - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4, - ) - qr.add_data(url) - qr.make(fit=True) - - qr_img = qr.make_image(fill_color="black", back_color="white") - qr_photoimg = ImageTk.PhotoImage(qr_img) - - self.qr_label.config(image=qr_photoimg) - self.qr_label.image = qr_photoimg - except ImportError: - self.qr_label.config(text="QR code generation requires 'qrcode' package") - except Exception as e: - print(f"Error generating QR code: {e}") - self.qr_label.config(text="Error generating QR code") - - def open_in_browser(self): - """Open the web interface in browser""" - if self.server_running: - webbrowser.open(f"http://localhost:{DEFAULT_PORT}") - - def on_minimize(self, event): - """Handle window minimize event""" - # Only minimize to tray if the window is being iconified, not just unmapped - if event.widget == self.root and self.root.state() == 'iconic': - self.root.withdraw() # Hide window - - def create_tray_icon(self): - """Create system tray icon""" - try: - # Create a simple icon image with M letter - icon_image = Image.new("RGB", (64, 64), THEME['accent_color']) - draw = ImageDraw.Draw(icon_image) - - try: - # Try to use a system font - font = ImageFont.truetype("arial.ttf", 40) - except: - try: - # Try other common fonts - font = ImageFont.truetype("calibri.ttf", 40) - except: - # Fall back to default font - font = ImageFont.load_default() - - # Draw "MP" in the center - text = "MP" - bbox = draw.textbbox((0, 0), text, font=font) - text_width = bbox[2] - bbox[0] - text_height = bbox[3] - bbox[1] - x = (64 - text_width) // 2 - y = (64 - text_height) // 2 - draw.text((x, y), text, fill="white", font=font) - - menu = ( - pystray.MenuItem('Show', self.show_window), - pystray.MenuItem('Exit', self.exit_app) - ) - - self.tray_icon = pystray.Icon("macropad", icon_image, "MacroPad Server", menu) - - # Run tray icon in a separate thread - tray_thread = threading.Thread(target=self.tray_icon.run, daemon=True) - tray_thread.start() - - except Exception as e: - print(f"Error creating tray icon: {e}") - # Tray icon is optional, continue without it - - def show_window(self, icon=None, item=None): - """Show window from tray""" - self.root.deiconify() - self.root.state('normal') - self.root.lift() - self.root.focus_force() - - def exit_app(self, icon=None, item=None): - """Exit the application""" - self.is_closing = True - self.stop_server() - - if self.tray_icon: - try: - self.tray_icon.stop() - except: - pass - - try: - self.root.quit() - except: - pass - - # Force exit if needed - import os - os._exit(0) - - def on_closing(self): - """Handle window close event - exit the application""" - self.exit_app() + # Run application + sys.exit(app.exec()) if __name__ == "__main__": - root = tk.Tk() - app = MacroPadServer(root) - root.mainloop() \ No newline at end of file + main() diff --git a/main.spec b/main.spec deleted file mode 100644 index 1ccbe3c..0000000 --- a/main.spec +++ /dev/null @@ -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, -) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8a46249 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index af65466..0000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -pillow -pyautogui -pystray -flask -waitress -netifaces -qrcode diff --git a/ui_components.py b/ui_components.py deleted file mode 100644 index 00d940d..0000000 --- a/ui_components.py +++ /dev/null @@ -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( - "", - 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() \ No newline at end of file diff --git a/version.txt b/version.txt index bbde4be..ac39a10 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.8.5 \ No newline at end of file +0.9.0 diff --git a/web/css/styles.css b/web/css/styles.css new file mode 100644 index 0000000..51eda75 --- /dev/null +++ b/web/css/styles.css @@ -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; +} diff --git a/web/icons/icon-192.png b/web/icons/icon-192.png new file mode 100644 index 0000000..17cd7c5 Binary files /dev/null and b/web/icons/icon-192.png differ diff --git a/web/icons/icon-512.png b/web/icons/icon-512.png new file mode 100644 index 0000000..7e1fd31 Binary files /dev/null and b/web/icons/icon-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..1d9b886 --- /dev/null +++ b/web/index.html @@ -0,0 +1,91 @@ + + + + + + + + + + + + MacroPad + + + + + + + + +
+

MacroPad

+
+
+
+ Disconnected +
+ + +
+
+ + + + + +
+
+
+
+
+ + + + + +
+ + + + diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..8e08f5f --- /dev/null +++ b/web/js/app.js @@ -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 => ` + + `).join(''); + } + + renderMacros() { + const container = document.getElementById('macro-grid'); + if (!container) return; + + const macroEntries = Object.entries(this.macros); + + if (macroEntries.length === 0) { + container.innerHTML = ` +
+

No macros found

+ +
+ `; + 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 ` +
+ + ${imageSrc + ? `${macro.name}` + : '' + } +
+ ${firstChar} +
+ ${macro.name} +
+ `; + }).join(''); + } + + renderCommandList() { + const container = document.getElementById('command-list'); + if (!container) return; + + if (this.commands.length === 0) { + container.innerHTML = '

No commands added yet

'; + 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 ` +
+ ${cmd.type} + ${displayValue} +
+ + + +
+
+ `; + }).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 = ` + Install MacroPad for quick access +
+ + +
+ `; + 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); + }); + }); +} diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..dcad7c3 --- /dev/null +++ b/web/manifest.json @@ -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"] +} diff --git a/web/service-worker.js b/web/service-worker.js new file mode 100644 index 0000000..4590ddf --- /dev/null +++ b/web/service-worker.js @@ -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('/'); + } + }) + ); +}); diff --git a/web_server.py b/web_server.py index 8403ab8..584ac7e 100644 --- a/web_server.py +++ b/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 -from web_templates import INDEX_HTML +import asyncio +from typing import List, Optional +from contextlib import asynccontextmanager + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, HTMLResponse +from pydantic import BaseModel +import uvicorn + +from config import DEFAULT_PORT, VERSION + + +class Command(BaseModel): + """Single command in a macro sequence.""" + type: str # text, key, hotkey, wait, app + value: Optional[str] = None + keys: Optional[List[str]] = None + ms: Optional[int] = None + command: Optional[str] = None + + +class MacroCreate(BaseModel): + """Request model for creating a macro.""" + name: str + commands: List[Command] + category: Optional[str] = "" + + +class MacroUpdate(BaseModel): + """Request model for updating a macro.""" + name: str + commands: List[Command] + category: Optional[str] = "" + + +class ExecuteRequest(BaseModel): + """Request model for executing a macro.""" + macro_id: str + + +class TabCreate(BaseModel): + """Request model for creating a tab.""" + name: str + + +class ConnectionManager: + """Manages WebSocket connections for real-time updates.""" + + def __init__(self): + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + if websocket in self.active_connections: + self.active_connections.remove(websocket) + + async def broadcast(self, message: dict): + """Send message to all connected clients.""" + for connection in self.active_connections: + try: + await connection.send_json(message) + except Exception: + pass class WebServer: - def __init__(self, macro_manager, app_dir, port=40000): + """FastAPI-based web server for MacroPad.""" + + def __init__(self, macro_manager, app_dir: str, port: int = DEFAULT_PORT): self.macro_manager = macro_manager - self.app_dir = app_dir + self.app_dir = app_dir self.port = port self.app = None - - def create_app(self): - """Create and configure Flask application""" - app = Flask(__name__) - - # Disable Flask's logging except for errors - log = logging.getLogger('werkzeug') - log.setLevel(logging.ERROR) - - @app.route('/') - def index(): - return render_template_string(INDEX_HTML) - - @app.route('/api/tabs') - def get_tabs(): - """Get all available tabs (similar to setup_tabs logic)""" - tabs = ["All"] - - # Add tabs based on macro types and custom categories - unique_types = set() - for macro in self.macro_manager.macros.values(): - if macro.get("type"): - unique_types.add(macro["type"].title()) - # Check for custom category - if macro.get("category"): - unique_types.add(macro["category"]) - - for tab_type in sorted(unique_types): - if tab_type not in ["All"]: - tabs.append(tab_type) - - return jsonify(tabs) - - @app.route('/api/macros') - def get_macros(): - return jsonify(self.macro_manager.macros) - - @app.route('/api/macros/') - def get_macros_by_tab(tab_name): - """Filter macros by tab (similar to filter_macros_by_tab logic)""" - if tab_name == "All": - return jsonify(self.macro_manager.macros) - - filtered_macros = {} - for macro_id, macro in self.macro_manager.macros.items(): - # Check type match - if macro.get("type", "").title() == tab_name: - filtered_macros[macro_id] = macro - # Check custom category match - elif macro.get("category") == tab_name: - filtered_macros[macro_id] = macro - - return jsonify(filtered_macros) - - @app.route('/api/image/') - def get_image(image_path): + self.manager = ConnectionManager() + self.server = None + + def create_app(self) -> FastAPI: + """Create FastAPI application.""" + + @asynccontextmanager + async def lifespan(app: FastAPI): + yield + + app = FastAPI( + title="MacroPad Server", + version=VERSION, + lifespan=lifespan + ) + + # Serve static files from web directory + web_dir = os.path.join(self.app_dir, "web") + if os.path.exists(web_dir): + app.mount("/static", StaticFiles(directory=web_dir), name="static") + + # Serve macro images + images_dir = os.path.join(self.app_dir, "macro_images") + if os.path.exists(images_dir): + app.mount("/images", StaticFiles(directory=images_dir), name="images") + + @app.get("/", response_class=HTMLResponse) + async def index(): + """Serve the main PWA page.""" + index_path = os.path.join(web_dir, "index.html") + if os.path.exists(index_path): + return FileResponse(index_path, media_type="text/html") + return HTMLResponse("

MacroPad Server

Web interface not found.

") + + @app.get("/manifest.json") + async def manifest(): + """Serve PWA manifest.""" + manifest_path = os.path.join(web_dir, "manifest.json") + if os.path.exists(manifest_path): + return FileResponse(manifest_path, media_type="application/json") + raise HTTPException(status_code=404, detail="Manifest not found") + + @app.get("/service-worker.js") + async def service_worker(): + """Serve service worker.""" + sw_path = os.path.join(web_dir, "service-worker.js") + if os.path.exists(sw_path): + return FileResponse(sw_path, media_type="application/javascript") + raise HTTPException(status_code=404, detail="Service worker not found") + + @app.get("/api/tabs") + async def get_tabs(): + """Get available tab categories.""" + return {"tabs": self.macro_manager.get_unique_tabs()} + + @app.get("/api/macros") + async def get_macros(): + """Get all macros.""" + macros = self.macro_manager.get_all_macros() + return {"macros": macros} + + @app.get("/api/macros/{tab}") + async def get_macros_by_tab(tab: str): + """Get macros filtered by tab/category.""" + all_macros = self.macro_manager.get_sorted_macros() + filtered = self.macro_manager.filter_macros_by_tab(all_macros, tab) + return {"macros": dict(filtered)} + + @app.get("/api/macro/{macro_id}") + async def get_macro(macro_id: str): + """Get a single macro by ID.""" + macro = self.macro_manager.get_macro(macro_id) + if macro: + return {"macro": macro} + raise HTTPException(status_code=404, detail="Macro not found") + + @app.post("/api/execute") + async def execute_macro(request: ExecuteRequest): + """Execute a macro by ID.""" + success = self.macro_manager.execute_macro(request.macro_id) + if success: + # Broadcast execution to all connected clients + await self.manager.broadcast({ + "type": "executed", + "macro_id": request.macro_id + }) + return {"success": True} + raise HTTPException(status_code=404, detail="Macro not found or execution failed") + + @app.post("/api/macros") + async def create_macro(macro: MacroCreate): + """Create a new macro.""" + commands = [cmd.model_dump(exclude_none=True) for cmd in macro.commands] + macro_id = self.macro_manager.add_macro( + name=macro.name, + commands=commands, + category=macro.category or "" + ) + # Broadcast update + await self.manager.broadcast({"type": "macro_created", "macro_id": macro_id}) + return {"success": True, "macro_id": macro_id} + + @app.put("/api/macros/{macro_id}") + async def update_macro(macro_id: str, macro: MacroUpdate): + """Update an existing macro.""" + commands = [cmd.model_dump(exclude_none=True) for cmd in macro.commands] + success = self.macro_manager.update_macro( + macro_id=macro_id, + name=macro.name, + commands=commands, + category=macro.category or "" + ) + if success: + await self.manager.broadcast({"type": "macro_updated", "macro_id": macro_id}) + return {"success": True} + raise HTTPException(status_code=404, detail="Macro not found") + + @app.delete("/api/macros/{macro_id}") + async def delete_macro(macro_id: str): + """Delete a macro.""" + success = self.macro_manager.delete_macro(macro_id) + if success: + await self.manager.broadcast({"type": "macro_deleted", "macro_id": macro_id}) + return {"success": True} + raise HTTPException(status_code=404, detail="Macro not found") + + @app.post("/api/upload-image") + async def upload_image(file: UploadFile = File(...)): + """Upload an image for a macro.""" + if not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="File must be an image") + + # Save to temp location + import tempfile + import shutil + + ext = os.path.splitext(file.filename)[1] if file.filename else ".png" + with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp: + shutil.copyfileobj(file.file, tmp) + return {"path": tmp.name} + + @app.get("/api/image/{image_path:path}") + async def get_image(image_path: str): + """Get macro image (legacy compatibility).""" + full_path = os.path.join(self.app_dir, image_path) + if os.path.exists(full_path): + return FileResponse(full_path) + raise HTTPException(status_code=404, detail="Image not found") + + @app.websocket("/ws") + async def websocket_endpoint(websocket: WebSocket): + """WebSocket for real-time updates.""" + await self.manager.connect(websocket) try: - image_path = os.path.join(self.app_dir, image_path) - return send_file(image_path) - except Exception as e: - return str(e), 404 - - @app.route('/api/execute', methods=['POST']) - def execute_macro(): - data = request.get_json() - if not data or 'macro_id' not in data: - return jsonify({"success": False, "error": "Invalid request"}) - - macro_id = data['macro_id'] - success = self.macro_manager.execute_macro(macro_id) - return jsonify({"success": success}) - + while True: + data = await websocket.receive_json() + # Handle incoming messages if needed + if data.get("type") == "ping": + await websocket.send_json({"type": "pong"}) + except WebSocketDisconnect: + self.manager.disconnect(websocket) + self.app = app return app - + def run(self): - """Run the web server""" + """Run the web server (blocking).""" if not self.app: self.create_app() - - try: - serve(self.app, host='0.0.0.0', port=self.port, threads=4) - except Exception as e: - raise e \ No newline at end of file + + config = uvicorn.Config( + self.app, + host="0.0.0.0", + port=self.port, + log_level="warning" + ) + server = uvicorn.Server(config) + server.run() + + async def run_async(self): + """Run the web server asynchronously.""" + if not self.app: + self.create_app() + + config = uvicorn.Config( + self.app, + host="0.0.0.0", + port=self.port, + log_level="warning" + ) + self.server = uvicorn.Server(config) + await self.server.serve() diff --git a/web_templates.py b/web_templates.py deleted file mode 100644 index 0ae168e..0000000 --- a/web_templates.py +++ /dev/null @@ -1,292 +0,0 @@ -# HTML templates for the web interface - -INDEX_HTML = ''' - - - - - -MacroPad Web Interface - - - -
-

MacroPad Web Interface

- -
-
Macro executed successfully!
-
Failed to execute macro
-
-
- -
-
-
- -
- - - -''' \ No newline at end of file