Modernize application to v0.9.0 with PySide6, FastAPI, and PWA support

## Major Changes

### Build System
- Replace requirements.txt with pyproject.toml for modern dependency management
- Support for uv package manager alongside pip
- Update PyInstaller spec files for new dependencies and structure

### Desktop GUI (Tkinter → PySide6)
- Complete rewrite of UI using PySide6/Qt6
- New modular structure in gui/ directory:
  - main_window.py: Main application window
  - macro_editor.py: Macro creation/editing dialog
  - command_builder.py: Visual command sequence builder
- Modern dark theme with consistent styling
- System tray integration

### Web Server (Flask → FastAPI)
- Migrate from Flask/Waitress to FastAPI/Uvicorn
- Add WebSocket support for real-time updates
- Full CRUD API for macro management
- Image upload endpoint

### Web Interface → PWA
- New web/ directory with standalone static files
- PWA manifest and service worker for installability
- Offline caching support
- Full macro editing from web interface
- Responsive mobile-first design
- Command builder UI matching desktop functionality

### Macro System Enhancement
- New command sequence model replacing simple text/app types
- Command types: text, key, hotkey, wait, app
- Support for delays between commands (wait in ms)
- Support for key presses between commands (enter, tab, etc.)
- Automatic migration of existing macros to new format
- Backward compatibility maintained

### Files Added
- pyproject.toml
- gui/__init__.py, main_window.py, macro_editor.py, command_builder.py
- gui/widgets/__init__.py
- web/index.html, manifest.json, service-worker.js
- web/css/styles.css, web/js/app.js
- web/icons/icon-192.png, icon-512.png

### Files Removed
- requirements.txt (replaced by pyproject.toml)
- ui_components.py (replaced by gui/ modules)
- web_templates.py (replaced by web/ static files)
- main.spec (consolidated into platform-specific specs)

### Files Modified
- main.py: Simplified entry point for PySide6
- macro_manager.py: Command sequence model and migration
- web_server.py: FastAPI implementation
- config.py: Version bump to 0.9.0
- All .spec files: Updated for PySide6 and new structure
- README.md: Complete rewrite for v0.9.0
- .gitea/workflows/release.yml: Disabled pending build testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-03 16:57:14 -08:00
parent ded281cc64
commit 5888aeb603
29 changed files with 3436 additions and 1604 deletions

7
gui/__init__.py Normal file
View File

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

5
gui/command_builder.py Normal file
View File

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

591
gui/macro_editor.py Normal file
View File

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

509
gui/main_window.py Normal file
View File

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

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

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