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