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:
7
gui/__init__.py
Normal file
7
gui/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# MacroPad Server GUI Module
|
||||
# PySide6-based desktop interface
|
||||
|
||||
from .main_window import MainWindow
|
||||
from .macro_editor import MacroEditorDialog, CommandBuilder
|
||||
|
||||
__all__ = ['MainWindow', 'MacroEditorDialog', 'CommandBuilder']
|
||||
5
gui/command_builder.py
Normal file
5
gui/command_builder.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# Command builder widget (re-exported from macro_editor for convenience)
|
||||
|
||||
from .macro_editor import CommandBuilder, CommandItem
|
||||
|
||||
__all__ = ['CommandBuilder', 'CommandItem']
|
||||
591
gui/macro_editor.py
Normal file
591
gui/macro_editor.py
Normal file
@@ -0,0 +1,591 @@
|
||||
# Macro editor dialog with command builder (PySide6)
|
||||
|
||||
import os
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||
QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem,
|
||||
QComboBox, QSpinBox, QMessageBox, QFileDialog, QWidget,
|
||||
QGroupBox, QScrollArea
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QPixmap, QIcon
|
||||
|
||||
from config import THEME, IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
class CommandItem(QWidget):
|
||||
"""Widget representing a single command in the list."""
|
||||
|
||||
delete_clicked = Signal()
|
||||
move_up_clicked = Signal()
|
||||
move_down_clicked = Signal()
|
||||
edit_clicked = Signal()
|
||||
|
||||
def __init__(self, command: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self.command = command
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# Type label
|
||||
type_label = QLabel(command.get("type", "").upper())
|
||||
type_label.setFixedWidth(60)
|
||||
type_label.setStyleSheet(f"""
|
||||
background-color: {THEME['accent_color']};
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
""")
|
||||
type_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(type_label)
|
||||
|
||||
# Value label
|
||||
value_label = QLabel(self._get_display_value())
|
||||
value_label.setStyleSheet(f"color: {THEME['fg_color']}; font-family: monospace;")
|
||||
layout.addWidget(value_label, 1)
|
||||
|
||||
# Action buttons
|
||||
btn_style = f"""
|
||||
QPushButton {{
|
||||
background-color: {THEME['button_bg']};
|
||||
color: {THEME['fg_color']};
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {THEME['highlight_color']};
|
||||
}}
|
||||
"""
|
||||
|
||||
edit_btn = QPushButton("Edit")
|
||||
edit_btn.setStyleSheet(btn_style)
|
||||
edit_btn.clicked.connect(self.edit_clicked.emit)
|
||||
layout.addWidget(edit_btn)
|
||||
|
||||
up_btn = QPushButton("^")
|
||||
up_btn.setStyleSheet(btn_style)
|
||||
up_btn.setFixedWidth(30)
|
||||
up_btn.clicked.connect(self.move_up_clicked.emit)
|
||||
layout.addWidget(up_btn)
|
||||
|
||||
down_btn = QPushButton("v")
|
||||
down_btn.setStyleSheet(btn_style)
|
||||
down_btn.setFixedWidth(30)
|
||||
down_btn.clicked.connect(self.move_down_clicked.emit)
|
||||
layout.addWidget(down_btn)
|
||||
|
||||
del_btn = QPushButton("X")
|
||||
del_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: #c82333;
|
||||
}}
|
||||
""")
|
||||
del_btn.setFixedWidth(30)
|
||||
del_btn.clicked.connect(self.delete_clicked.emit)
|
||||
layout.addWidget(del_btn)
|
||||
|
||||
def _get_display_value(self) -> str:
|
||||
"""Get display text for the command."""
|
||||
cmd_type = self.command.get("type", "")
|
||||
if cmd_type == "text":
|
||||
return self.command.get("value", "")[:50]
|
||||
elif cmd_type == "key":
|
||||
return self.command.get("value", "")
|
||||
elif cmd_type == "hotkey":
|
||||
return " + ".join(self.command.get("keys", []))
|
||||
elif cmd_type == "wait":
|
||||
return f"{self.command.get('ms', 0)}ms"
|
||||
elif cmd_type == "app":
|
||||
return self.command.get("command", "")[:50]
|
||||
return ""
|
||||
|
||||
|
||||
class CommandBuilder(QWidget):
|
||||
"""Widget for building command sequences."""
|
||||
|
||||
commands_changed = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.commands: List[dict] = []
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Command list
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.setStyleSheet(f"""
|
||||
QListWidget {{
|
||||
background-color: {THEME['bg_color']};
|
||||
border: 1px solid {THEME['button_bg']};
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QListWidget::item {{
|
||||
padding: 4px;
|
||||
}}
|
||||
""")
|
||||
self.list_widget.setMinimumHeight(150)
|
||||
layout.addWidget(self.list_widget)
|
||||
|
||||
# Add command buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
|
||||
for cmd_type, label in [
|
||||
("text", "+ Text"),
|
||||
("key", "+ Key"),
|
||||
("hotkey", "+ Hotkey"),
|
||||
("wait", "+ Wait"),
|
||||
("app", "+ App")
|
||||
]:
|
||||
btn = QPushButton(label)
|
||||
btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {THEME['button_bg']};
|
||||
color: {THEME['fg_color']};
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {THEME['accent_color']};
|
||||
}}
|
||||
""")
|
||||
btn.clicked.connect(lambda checked, t=cmd_type: self.add_command(t))
|
||||
btn_layout.addWidget(btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def set_commands(self, commands: List[dict]):
|
||||
"""Set the command list."""
|
||||
self.commands = list(commands)
|
||||
self.refresh()
|
||||
|
||||
def get_commands(self) -> List[dict]:
|
||||
"""Get the command list."""
|
||||
return list(self.commands)
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the command list display."""
|
||||
self.list_widget.clear()
|
||||
|
||||
for i, cmd in enumerate(self.commands):
|
||||
item = QListWidgetItem(self.list_widget)
|
||||
widget = CommandItem(cmd)
|
||||
widget.delete_clicked.connect(lambda idx=i: self.remove_command(idx))
|
||||
widget.move_up_clicked.connect(lambda idx=i: self.move_command(idx, -1))
|
||||
widget.move_down_clicked.connect(lambda idx=i: self.move_command(idx, 1))
|
||||
widget.edit_clicked.connect(lambda idx=i: self.edit_command(idx))
|
||||
|
||||
item.setSizeHint(widget.sizeHint())
|
||||
self.list_widget.addItem(item)
|
||||
self.list_widget.setItemWidget(item, widget)
|
||||
|
||||
def add_command(self, cmd_type: str):
|
||||
"""Add a new command."""
|
||||
command = {"type": cmd_type}
|
||||
|
||||
if cmd_type == "text":
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
text, ok = QInputDialog.getText(self, "Text Command", "Enter text to type:")
|
||||
if not ok or not text:
|
||||
return
|
||||
command["value"] = text
|
||||
|
||||
elif cmd_type == "key":
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
keys = ["enter", "tab", "escape", "space", "backspace", "delete",
|
||||
"up", "down", "left", "right", "home", "end", "pageup", "pagedown",
|
||||
"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12"]
|
||||
key, ok = QInputDialog.getItem(self, "Key Command", "Select key:", keys, 0, True)
|
||||
if not ok or not key:
|
||||
return
|
||||
command["value"] = key.lower()
|
||||
|
||||
elif cmd_type == "hotkey":
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
text, ok = QInputDialog.getText(
|
||||
self, "Hotkey Command",
|
||||
"Enter key combination (comma separated, e.g., ctrl,c):"
|
||||
)
|
||||
if not ok or not text:
|
||||
return
|
||||
command["keys"] = [k.strip().lower() for k in text.split(",")]
|
||||
|
||||
elif cmd_type == "wait":
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
ms, ok = QInputDialog.getInt(
|
||||
self, "Wait Command",
|
||||
"Enter delay in milliseconds:",
|
||||
500, 0, 60000, 100
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
command["ms"] = ms
|
||||
|
||||
elif cmd_type == "app":
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
cmd, ok = QInputDialog.getText(self, "App Command", "Enter application command:")
|
||||
if not ok or not cmd:
|
||||
return
|
||||
command["command"] = cmd
|
||||
|
||||
self.commands.append(command)
|
||||
self.refresh()
|
||||
self.commands_changed.emit()
|
||||
|
||||
def remove_command(self, index: int):
|
||||
"""Remove a command at index."""
|
||||
if 0 <= index < len(self.commands):
|
||||
del self.commands[index]
|
||||
self.refresh()
|
||||
self.commands_changed.emit()
|
||||
|
||||
def move_command(self, index: int, direction: int):
|
||||
"""Move a command up or down."""
|
||||
new_index = index + direction
|
||||
if 0 <= new_index < len(self.commands):
|
||||
self.commands[index], self.commands[new_index] = \
|
||||
self.commands[new_index], self.commands[index]
|
||||
self.refresh()
|
||||
self.commands_changed.emit()
|
||||
|
||||
def edit_command(self, index: int):
|
||||
"""Edit a command at index."""
|
||||
if not (0 <= index < len(self.commands)):
|
||||
return
|
||||
|
||||
cmd = self.commands[index]
|
||||
cmd_type = cmd.get("type", "")
|
||||
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
if cmd_type == "text":
|
||||
text, ok = QInputDialog.getText(
|
||||
self, "Edit Text", "Enter text:",
|
||||
text=cmd.get("value", "")
|
||||
)
|
||||
if ok and text:
|
||||
cmd["value"] = text
|
||||
|
||||
elif cmd_type == "key":
|
||||
keys = ["enter", "tab", "escape", "space", "backspace", "delete",
|
||||
"up", "down", "left", "right", "home", "end", "pageup", "pagedown",
|
||||
"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12"]
|
||||
current = keys.index(cmd.get("value", "enter")) if cmd.get("value") in keys else 0
|
||||
key, ok = QInputDialog.getItem(self, "Edit Key", "Select key:", keys, current, True)
|
||||
if ok and key:
|
||||
cmd["value"] = key.lower()
|
||||
|
||||
elif cmd_type == "hotkey":
|
||||
text, ok = QInputDialog.getText(
|
||||
self, "Edit Hotkey", "Enter key combination:",
|
||||
text=",".join(cmd.get("keys", []))
|
||||
)
|
||||
if ok and text:
|
||||
cmd["keys"] = [k.strip().lower() for k in text.split(",")]
|
||||
|
||||
elif cmd_type == "wait":
|
||||
ms, ok = QInputDialog.getInt(
|
||||
self, "Edit Wait", "Enter delay in milliseconds:",
|
||||
cmd.get("ms", 500), 0, 60000, 100
|
||||
)
|
||||
if ok:
|
||||
cmd["ms"] = ms
|
||||
|
||||
elif cmd_type == "app":
|
||||
text, ok = QInputDialog.getText(
|
||||
self, "Edit App", "Enter application command:",
|
||||
text=cmd.get("command", "")
|
||||
)
|
||||
if ok and text:
|
||||
cmd["command"] = text
|
||||
|
||||
self.refresh()
|
||||
self.commands_changed.emit()
|
||||
|
||||
|
||||
class MacroEditorDialog(QDialog):
|
||||
"""Dialog for creating/editing macros."""
|
||||
|
||||
def __init__(self, macro_manager, macro_id: Optional[str] = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.macro_manager = macro_manager
|
||||
self.macro_id = macro_id
|
||||
self.image_path = ""
|
||||
|
||||
self.setWindowTitle("Edit Macro" if macro_id else "Add Macro")
|
||||
self.setMinimumSize(500, 500)
|
||||
self.setStyleSheet(f"""
|
||||
QDialog {{
|
||||
background-color: {THEME['highlight_color']};
|
||||
}}
|
||||
QLabel {{
|
||||
color: {THEME['fg_color']};
|
||||
}}
|
||||
QLineEdit {{
|
||||
background-color: {THEME['bg_color']};
|
||||
border: 1px solid {THEME['button_bg']};
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
color: {THEME['fg_color']};
|
||||
}}
|
||||
QLineEdit:focus {{
|
||||
border-color: {THEME['accent_color']};
|
||||
}}
|
||||
""")
|
||||
|
||||
self.setup_ui()
|
||||
|
||||
# Load existing macro data if editing
|
||||
if macro_id:
|
||||
self.load_macro()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Setup the dialog UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Scroll area for content
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setStyleSheet("border: none;")
|
||||
|
||||
content = QWidget()
|
||||
content_layout = QVBoxLayout(content)
|
||||
content_layout.setSpacing(12)
|
||||
|
||||
# Name field
|
||||
name_group = QGroupBox("Macro Name")
|
||||
name_group.setStyleSheet(f"""
|
||||
QGroupBox {{
|
||||
color: {THEME['fg_color']};
|
||||
font-weight: bold;
|
||||
border: 1px solid {THEME['button_bg']};
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
}}
|
||||
QGroupBox::title {{
|
||||
subcontrol-origin: margin;
|
||||
left: 10px;
|
||||
}}
|
||||
""")
|
||||
name_layout = QVBoxLayout(name_group)
|
||||
self.name_input = QLineEdit()
|
||||
self.name_input.setPlaceholderText("Enter macro name")
|
||||
name_layout.addWidget(self.name_input)
|
||||
content_layout.addWidget(name_group)
|
||||
|
||||
# Category field
|
||||
category_group = QGroupBox("Category (optional)")
|
||||
category_group.setStyleSheet(name_group.styleSheet())
|
||||
category_layout = QVBoxLayout(category_group)
|
||||
self.category_input = QLineEdit()
|
||||
self.category_input.setPlaceholderText("Enter category")
|
||||
category_layout.addWidget(self.category_input)
|
||||
content_layout.addWidget(category_group)
|
||||
|
||||
# Command builder
|
||||
commands_group = QGroupBox("Commands")
|
||||
commands_group.setStyleSheet(name_group.styleSheet())
|
||||
commands_layout = QVBoxLayout(commands_group)
|
||||
self.command_builder = CommandBuilder()
|
||||
commands_layout.addWidget(self.command_builder)
|
||||
content_layout.addWidget(commands_group)
|
||||
|
||||
# Image selection
|
||||
image_group = QGroupBox("Image (optional)")
|
||||
image_group.setStyleSheet(name_group.styleSheet())
|
||||
image_layout = QHBoxLayout(image_group)
|
||||
|
||||
self.image_preview = QLabel()
|
||||
self.image_preview.setFixedSize(64, 64)
|
||||
self.image_preview.setStyleSheet(f"""
|
||||
background-color: {THEME['bg_color']};
|
||||
border-radius: 4px;
|
||||
""")
|
||||
self.image_preview.setAlignment(Qt.AlignCenter)
|
||||
image_layout.addWidget(self.image_preview)
|
||||
|
||||
image_btn_layout = QVBoxLayout()
|
||||
select_btn = QPushButton("Select Image")
|
||||
select_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {THEME['button_bg']};
|
||||
color: {THEME['fg_color']};
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {THEME['accent_color']};
|
||||
}}
|
||||
""")
|
||||
select_btn.clicked.connect(self.select_image)
|
||||
image_btn_layout.addWidget(select_btn)
|
||||
|
||||
clear_btn = QPushButton("Clear Image")
|
||||
clear_btn.setStyleSheet(select_btn.styleSheet())
|
||||
clear_btn.clicked.connect(self.clear_image)
|
||||
image_btn_layout.addWidget(clear_btn)
|
||||
|
||||
image_layout.addLayout(image_btn_layout)
|
||||
image_layout.addStretch()
|
||||
content_layout.addWidget(image_group)
|
||||
|
||||
content_layout.addStretch()
|
||||
scroll.setWidget(content)
|
||||
layout.addWidget(scroll)
|
||||
|
||||
# Dialog buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
|
||||
if self.macro_id:
|
||||
delete_btn = QPushButton("Delete")
|
||||
delete_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: #c82333;
|
||||
}}
|
||||
""")
|
||||
delete_btn.clicked.connect(self.delete_macro)
|
||||
btn_layout.addWidget(delete_btn)
|
||||
|
||||
btn_layout.addStretch()
|
||||
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {THEME['button_bg']};
|
||||
color: {THEME['fg_color']};
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {THEME['highlight_color']};
|
||||
}}
|
||||
""")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_layout.addWidget(cancel_btn)
|
||||
|
||||
save_btn = QPushButton("Save")
|
||||
save_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {THEME['accent_color']};
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: #0096ff;
|
||||
}}
|
||||
""")
|
||||
save_btn.clicked.connect(self.save_macro)
|
||||
btn_layout.addWidget(save_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def load_macro(self):
|
||||
"""Load existing macro data into the form."""
|
||||
macro = self.macro_manager.get_macro(self.macro_id)
|
||||
if not macro:
|
||||
return
|
||||
|
||||
self.name_input.setText(macro.get("name", ""))
|
||||
self.category_input.setText(macro.get("category", ""))
|
||||
self.command_builder.set_commands(macro.get("commands", []))
|
||||
|
||||
if macro.get("image_path"):
|
||||
self.image_path = macro["image_path"]
|
||||
pixmap = QPixmap(self.image_path)
|
||||
if not pixmap.isNull():
|
||||
self.image_preview.setPixmap(
|
||||
pixmap.scaled(64, 64, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
)
|
||||
|
||||
def select_image(self):
|
||||
"""Open file dialog to select an image."""
|
||||
ext_filter = "Images (" + " ".join(f"*{ext}" for ext in IMAGE_EXTENSIONS) + ")"
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select Image", "", ext_filter
|
||||
)
|
||||
if file_path:
|
||||
self.image_path = file_path
|
||||
pixmap = QPixmap(file_path)
|
||||
self.image_preview.setPixmap(
|
||||
pixmap.scaled(64, 64, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
)
|
||||
|
||||
def clear_image(self):
|
||||
"""Clear the selected image."""
|
||||
self.image_path = ""
|
||||
self.image_preview.clear()
|
||||
|
||||
def save_macro(self):
|
||||
"""Save the macro."""
|
||||
name = self.name_input.text().strip()
|
||||
if not name:
|
||||
QMessageBox.warning(self, "Error", "Please enter a macro name")
|
||||
return
|
||||
|
||||
commands = self.command_builder.get_commands()
|
||||
if not commands:
|
||||
QMessageBox.warning(self, "Error", "Please add at least one command")
|
||||
return
|
||||
|
||||
category = self.category_input.text().strip()
|
||||
|
||||
if self.macro_id:
|
||||
# Update existing macro
|
||||
self.macro_manager.update_macro(
|
||||
self.macro_id,
|
||||
name=name,
|
||||
commands=commands,
|
||||
category=category,
|
||||
image_path=self.image_path if self.image_path else None
|
||||
)
|
||||
else:
|
||||
# Create new macro
|
||||
self.macro_manager.add_macro(
|
||||
name=name,
|
||||
commands=commands,
|
||||
category=category,
|
||||
image_path=self.image_path
|
||||
)
|
||||
|
||||
self.accept()
|
||||
|
||||
def delete_macro(self):
|
||||
"""Delete the current macro."""
|
||||
reply = QMessageBox.question(
|
||||
self, "Delete Macro",
|
||||
"Are you sure you want to delete this macro?",
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.macro_manager.delete_macro(self.macro_id)
|
||||
self.accept()
|
||||
509
gui/main_window.py
Normal file
509
gui/main_window.py
Normal file
@@ -0,0 +1,509 @@
|
||||
# Main window for MacroPad Server (PySide6)
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QPushButton, QTabWidget, QGridLayout,
|
||||
QScrollArea, QFrame, QMenu, QMenuBar, QStatusBar,
|
||||
QMessageBox, QApplication, QSystemTrayIcon
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal, QTimer, QSize
|
||||
from PySide6.QtGui import QIcon, QPixmap, QAction, QFont
|
||||
|
||||
from config import VERSION, THEME, DEFAULT_PORT
|
||||
from macro_manager import MacroManager
|
||||
from web_server import WebServer
|
||||
|
||||
|
||||
class MacroButton(QPushButton):
|
||||
"""Custom button widget for displaying a macro."""
|
||||
|
||||
def __init__(self, macro_id: str, macro: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self.macro_id = macro_id
|
||||
self.macro = macro
|
||||
|
||||
self.setFixedSize(120, 100)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {THEME['button_bg']};
|
||||
color: {THEME['fg_color']};
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {THEME['highlight_color']};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background-color: {THEME['accent_color']};
|
||||
}}
|
||||
""")
|
||||
|
||||
# Layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(4)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
|
||||
# Image or placeholder
|
||||
image_label = QLabel()
|
||||
image_label.setFixedSize(48, 48)
|
||||
image_label.setAlignment(Qt.AlignCenter)
|
||||
|
||||
if macro.get("image_path"):
|
||||
pixmap = QPixmap(macro["image_path"])
|
||||
if not pixmap.isNull():
|
||||
pixmap = pixmap.scaled(48, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
image_label.setPixmap(pixmap)
|
||||
else:
|
||||
self._set_placeholder(image_label, macro["name"])
|
||||
else:
|
||||
self._set_placeholder(image_label, macro["name"])
|
||||
|
||||
layout.addWidget(image_label, alignment=Qt.AlignCenter)
|
||||
|
||||
# Name label
|
||||
name_label = QLabel(macro["name"])
|
||||
name_label.setAlignment(Qt.AlignCenter)
|
||||
name_label.setWordWrap(True)
|
||||
name_label.setStyleSheet(f"color: {THEME['fg_color']}; font-size: 11px;")
|
||||
layout.addWidget(name_label)
|
||||
|
||||
def _set_placeholder(self, label: QLabel, name: str):
|
||||
"""Set a placeholder with the first letter of the name."""
|
||||
label.setStyleSheet(f"""
|
||||
background-color: {THEME['highlight_color']};
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: {THEME['fg_color']};
|
||||
""")
|
||||
label.setText(name[0].upper() if name else "?")
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
"""Show context menu on right-click."""
|
||||
menu = QMenu(self)
|
||||
edit_action = menu.addAction("Edit")
|
||||
delete_action = menu.addAction("Delete")
|
||||
|
||||
action = menu.exec_(event.globalPos())
|
||||
if action == edit_action:
|
||||
self.parent().parent().parent().parent().edit_macro(self.macro_id)
|
||||
elif action == delete_action:
|
||||
self.parent().parent().parent().parent().delete_macro(self.macro_id)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Main application window."""
|
||||
|
||||
macros_changed = Signal()
|
||||
|
||||
def __init__(self, app_dir: str):
|
||||
super().__init__()
|
||||
self.app_dir = app_dir
|
||||
self.current_tab = "All"
|
||||
self.sort_by = "name"
|
||||
|
||||
# Initialize macro manager
|
||||
data_file = os.path.join(app_dir, "macros.json")
|
||||
images_dir = os.path.join(app_dir, "macro_images")
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
|
||||
self.macro_manager = MacroManager(data_file, images_dir, app_dir)
|
||||
|
||||
# Initialize web server
|
||||
self.web_server = WebServer(self.macro_manager, app_dir, DEFAULT_PORT)
|
||||
self.server_thread = None
|
||||
|
||||
# Setup UI
|
||||
self.setup_ui()
|
||||
self.setup_menu()
|
||||
self.setup_tray()
|
||||
|
||||
# Start web server
|
||||
self.start_server()
|
||||
|
||||
# Connect signals
|
||||
self.macros_changed.connect(self.refresh_macros)
|
||||
|
||||
# Load initial data
|
||||
self.refresh_tabs()
|
||||
self.refresh_macros()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Setup the main UI components."""
|
||||
self.setWindowTitle(f"MacroPad Server v{VERSION}")
|
||||
self.setMinimumSize(600, 400)
|
||||
self.setStyleSheet(f"background-color: {THEME['bg_color']};")
|
||||
|
||||
# Central widget
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
layout = QVBoxLayout(central)
|
||||
layout.setSpacing(0)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Toolbar
|
||||
toolbar = QWidget()
|
||||
toolbar.setStyleSheet(f"background-color: {THEME['highlight_color']};")
|
||||
toolbar_layout = QHBoxLayout(toolbar)
|
||||
toolbar_layout.setContentsMargins(10, 10, 10, 10)
|
||||
|
||||
add_btn = QPushButton("+ Add Macro")
|
||||
add_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {THEME['accent_color']};
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: #0096ff;
|
||||
}}
|
||||
""")
|
||||
add_btn.clicked.connect(self.add_macro)
|
||||
toolbar_layout.addWidget(add_btn)
|
||||
|
||||
toolbar_layout.addStretch()
|
||||
|
||||
# IP address label
|
||||
self.ip_label = QLabel()
|
||||
self.ip_label.setStyleSheet(f"color: {THEME['fg_color']};")
|
||||
self.update_ip_label()
|
||||
toolbar_layout.addWidget(self.ip_label)
|
||||
|
||||
qr_btn = QPushButton("QR Code")
|
||||
qr_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {THEME['button_bg']};
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {THEME['highlight_color']};
|
||||
}}
|
||||
""")
|
||||
qr_btn.clicked.connect(self.show_qr_code)
|
||||
toolbar_layout.addWidget(qr_btn)
|
||||
|
||||
layout.addWidget(toolbar)
|
||||
|
||||
# Tab widget
|
||||
self.tab_widget = QTabWidget()
|
||||
self.tab_widget.setStyleSheet(f"""
|
||||
QTabWidget::pane {{
|
||||
border: none;
|
||||
background: {THEME['bg_color']};
|
||||
}}
|
||||
QTabBar::tab {{
|
||||
background: {THEME['tab_bg']};
|
||||
color: {THEME['fg_color']};
|
||||
padding: 8px 16px;
|
||||
margin-right: 2px;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
background: {THEME['tab_selected']};
|
||||
}}
|
||||
QTabBar::tab:hover {{
|
||||
background: {THEME['highlight_color']};
|
||||
}}
|
||||
""")
|
||||
self.tab_widget.currentChanged.connect(self.on_tab_changed)
|
||||
layout.addWidget(self.tab_widget)
|
||||
|
||||
# Status bar
|
||||
self.status_bar = QStatusBar()
|
||||
self.status_bar.setStyleSheet(f"""
|
||||
background-color: {THEME['highlight_color']};
|
||||
color: {THEME['fg_color']};
|
||||
""")
|
||||
self.setStatusBar(self.status_bar)
|
||||
self.status_bar.showMessage("Ready")
|
||||
|
||||
def setup_menu(self):
|
||||
"""Setup the menu bar."""
|
||||
menubar = self.menuBar()
|
||||
menubar.setStyleSheet(f"""
|
||||
QMenuBar {{
|
||||
background-color: {THEME['highlight_color']};
|
||||
color: {THEME['fg_color']};
|
||||
}}
|
||||
QMenuBar::item:selected {{
|
||||
background-color: {THEME['accent_color']};
|
||||
}}
|
||||
QMenu {{
|
||||
background-color: {THEME['highlight_color']};
|
||||
color: {THEME['fg_color']};
|
||||
}}
|
||||
QMenu::item:selected {{
|
||||
background-color: {THEME['accent_color']};
|
||||
}}
|
||||
""")
|
||||
|
||||
# File menu
|
||||
file_menu = menubar.addMenu("File")
|
||||
|
||||
add_action = QAction("Add Macro", self)
|
||||
add_action.setShortcut("Ctrl+N")
|
||||
add_action.triggered.connect(self.add_macro)
|
||||
file_menu.addAction(add_action)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
quit_action = QAction("Quit", self)
|
||||
quit_action.setShortcut("Ctrl+Q")
|
||||
quit_action.triggered.connect(self.close)
|
||||
file_menu.addAction(quit_action)
|
||||
|
||||
# View menu
|
||||
view_menu = menubar.addMenu("View")
|
||||
|
||||
refresh_action = QAction("Refresh", self)
|
||||
refresh_action.setShortcut("F5")
|
||||
refresh_action.triggered.connect(self.refresh_all)
|
||||
view_menu.addAction(refresh_action)
|
||||
|
||||
# Sort submenu
|
||||
sort_menu = view_menu.addMenu("Sort By")
|
||||
for sort_option in [("Name", "name"), ("Type", "type"), ("Recent", "recent")]:
|
||||
action = QAction(sort_option[0], self)
|
||||
action.triggered.connect(lambda checked, s=sort_option[1]: self.set_sort(s))
|
||||
sort_menu.addAction(action)
|
||||
|
||||
# Help menu
|
||||
help_menu = menubar.addMenu("Help")
|
||||
|
||||
about_action = QAction("About", self)
|
||||
about_action.triggered.connect(self.show_about)
|
||||
help_menu.addAction(about_action)
|
||||
|
||||
def setup_tray(self):
|
||||
"""Setup system tray icon."""
|
||||
self.tray_icon = QSystemTrayIcon(self)
|
||||
|
||||
# Load icon
|
||||
icon_path = os.path.join(self.app_dir, "Macro Pad.png")
|
||||
if os.path.exists(icon_path):
|
||||
self.tray_icon.setIcon(QIcon(icon_path))
|
||||
self.setWindowIcon(QIcon(icon_path))
|
||||
else:
|
||||
self.tray_icon.setIcon(self.style().standardIcon(self.style().SP_ComputerIcon))
|
||||
|
||||
# Tray menu
|
||||
tray_menu = QMenu()
|
||||
show_action = tray_menu.addAction("Show")
|
||||
show_action.triggered.connect(self.show)
|
||||
quit_action = tray_menu.addAction("Quit")
|
||||
quit_action.triggered.connect(self.close)
|
||||
|
||||
self.tray_icon.setContextMenu(tray_menu)
|
||||
self.tray_icon.activated.connect(self.on_tray_activated)
|
||||
self.tray_icon.show()
|
||||
|
||||
def on_tray_activated(self, reason):
|
||||
"""Handle tray icon activation."""
|
||||
if reason == QSystemTrayIcon.DoubleClick:
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
|
||||
def start_server(self):
|
||||
"""Start the web server in a background thread."""
|
||||
self.web_server.create_app()
|
||||
|
||||
def run():
|
||||
self.web_server.run()
|
||||
|
||||
self.server_thread = threading.Thread(target=run, daemon=True)
|
||||
self.server_thread.start()
|
||||
self.status_bar.showMessage(f"Server running on port {DEFAULT_PORT}")
|
||||
|
||||
def update_ip_label(self):
|
||||
"""Update the IP address label."""
|
||||
try:
|
||||
import netifaces
|
||||
for iface in netifaces.interfaces():
|
||||
addrs = netifaces.ifaddresses(iface)
|
||||
if netifaces.AF_INET in addrs:
|
||||
for addr in addrs[netifaces.AF_INET]:
|
||||
ip = addr.get('addr', '')
|
||||
if ip and not ip.startswith('127.'):
|
||||
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}")
|
||||
|
||||
def refresh_tabs(self):
|
||||
"""Refresh the tab widget."""
|
||||
self.tab_widget.blockSignals(True)
|
||||
self.tab_widget.clear()
|
||||
|
||||
tabs = self.macro_manager.get_unique_tabs()
|
||||
for tab_name in tabs:
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setStyleSheet(f"background-color: {THEME['bg_color']}; border: none;")
|
||||
|
||||
container = QWidget()
|
||||
container.setStyleSheet(f"background-color: {THEME['bg_color']};")
|
||||
scroll.setWidget(container)
|
||||
|
||||
self.tab_widget.addTab(scroll, tab_name)
|
||||
|
||||
self.tab_widget.blockSignals(False)
|
||||
|
||||
def refresh_macros(self):
|
||||
"""Refresh the macro grid."""
|
||||
current_index = self.tab_widget.currentIndex()
|
||||
if current_index < 0:
|
||||
return
|
||||
|
||||
scroll = self.tab_widget.widget(current_index)
|
||||
container = scroll.widget()
|
||||
|
||||
# Clear existing layout
|
||||
if container.layout():
|
||||
while container.layout().count():
|
||||
item = container.layout().takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
else:
|
||||
container.setLayout(QGridLayout())
|
||||
|
||||
layout = container.layout()
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(10, 10, 10, 10)
|
||||
layout.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||||
|
||||
# Get macros for current tab
|
||||
tab_name = self.tab_widget.tabText(current_index)
|
||||
macro_list = self.macro_manager.get_sorted_macros(self.sort_by)
|
||||
filtered = self.macro_manager.filter_macros_by_tab(macro_list, tab_name)
|
||||
|
||||
# Add macro buttons
|
||||
cols = max(1, (self.width() - 40) // 130)
|
||||
for i, (macro_id, macro) in enumerate(filtered):
|
||||
btn = MacroButton(macro_id, macro)
|
||||
btn.clicked.connect(lambda checked, mid=macro_id: self.execute_macro(mid))
|
||||
layout.addWidget(btn, i // cols, i % cols)
|
||||
|
||||
def on_tab_changed(self, index):
|
||||
"""Handle tab change."""
|
||||
self.refresh_macros()
|
||||
|
||||
def execute_macro(self, macro_id: str):
|
||||
"""Execute a macro."""
|
||||
success = self.macro_manager.execute_macro(macro_id)
|
||||
if success:
|
||||
self.status_bar.showMessage("Macro executed", 2000)
|
||||
else:
|
||||
self.status_bar.showMessage("Macro execution failed", 2000)
|
||||
|
||||
def add_macro(self):
|
||||
"""Open dialog to add a new macro."""
|
||||
from .macro_editor import MacroEditorDialog
|
||||
dialog = MacroEditorDialog(self.macro_manager, parent=self)
|
||||
if dialog.exec_():
|
||||
self.refresh_tabs()
|
||||
self.refresh_macros()
|
||||
|
||||
def edit_macro(self, macro_id: str):
|
||||
"""Open dialog to edit a macro."""
|
||||
from .macro_editor import MacroEditorDialog
|
||||
dialog = MacroEditorDialog(self.macro_manager, macro_id=macro_id, parent=self)
|
||||
if dialog.exec_():
|
||||
self.refresh_tabs()
|
||||
self.refresh_macros()
|
||||
|
||||
def delete_macro(self, macro_id: str):
|
||||
"""Delete a macro with confirmation."""
|
||||
reply = QMessageBox.question(
|
||||
self, "Delete Macro",
|
||||
"Are you sure you want to delete this macro?",
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.macro_manager.delete_macro(macro_id)
|
||||
self.refresh_tabs()
|
||||
self.refresh_macros()
|
||||
|
||||
def set_sort(self, sort_by: str):
|
||||
"""Set the sort order."""
|
||||
self.sort_by = sort_by
|
||||
self.refresh_macros()
|
||||
|
||||
def refresh_all(self):
|
||||
"""Refresh tabs and macros."""
|
||||
self.refresh_tabs()
|
||||
self.refresh_macros()
|
||||
|
||||
def show_qr_code(self):
|
||||
"""Show QR code dialog."""
|
||||
try:
|
||||
import qrcode
|
||||
from PySide6.QtWidgets import QDialog, QVBoxLayout
|
||||
from io import BytesIO
|
||||
|
||||
url = self.ip_label.text()
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Convert to QPixmap
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
pixmap = QPixmap()
|
||||
pixmap.loadFromData(buffer.read())
|
||||
|
||||
# Show dialog
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("QR Code")
|
||||
layout = QVBoxLayout(dialog)
|
||||
|
||||
label = QLabel()
|
||||
label.setPixmap(pixmap.scaled(300, 300, Qt.KeepAspectRatio, Qt.SmoothTransformation))
|
||||
layout.addWidget(label)
|
||||
|
||||
url_label = QLabel(url)
|
||||
url_label.setStyleSheet(f"color: {THEME['fg_color']};")
|
||||
url_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(url_label)
|
||||
|
||||
dialog.exec_()
|
||||
except ImportError:
|
||||
QMessageBox.warning(self, "Error", "QR code library not available")
|
||||
|
||||
def show_about(self):
|
||||
"""Show about dialog."""
|
||||
QMessageBox.about(
|
||||
self, "About MacroPad Server",
|
||||
f"MacroPad Server v{VERSION}\n\n"
|
||||
"A cross-platform macro management application\n"
|
||||
"with desktop and web interfaces.\n\n"
|
||||
"PWA-enabled for mobile devices."
|
||||
)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle window close."""
|
||||
self.tray_icon.hide()
|
||||
event.accept()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Handle window resize."""
|
||||
super().resizeEvent(event)
|
||||
self.refresh_macros()
|
||||
1
gui/widgets/__init__.py
Normal file
1
gui/widgets/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# MacroPad Server GUI Widgets
|
||||
Reference in New Issue
Block a user