# Main window for MacroPad Server (PySide6) import os import sys import threading from typing import Optional # Windows startup management if sys.platform == 'win32': import winreg def get_resource_path(relative_path): """Get the path to a bundled resource file.""" if getattr(sys, 'frozen', False): base_path = sys._MEIPASS else: base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.join(base_path, relative_path) from PySide6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTabWidget, QGridLayout, QScrollArea, QMenu, QStatusBar, QMessageBox, QApplication, QSystemTrayIcon, QStyle ) from PySide6.QtCore import Qt, Signal, QTimer, QEvent from PySide6.QtGui import QIcon, QPixmap, QAction from config import VERSION, THEME, DEFAULT_PORT, SETTINGS_FILE from macro_manager import MacroManager from web_server import WebServer from .settings_manager import SettingsManager class MacroButton(QPushButton): """Custom button widget for displaying a macro.""" # Signals for context menu actions edit_requested = Signal(str) delete_requested = Signal(str) def __init__(self, macro_id: str, macro: dict, app_dir: str, 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"): # Resolve relative image path against app directory image_path = os.path.join(app_dir, macro["image_path"]) pixmap = QPixmap(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.edit_requested.emit(self.macro_id) elif action == delete_action: self.delete_requested.emit(self.macro_id) class MainWindow(QMainWindow): """Main application window.""" macros_changed = Signal() # Signals for thread-safe relay status updates relay_session_received = Signal(str) relay_connected_signal = Signal() relay_disconnected_signal = Signal() # Report macro execution result back to the GUI thread (success flag). macro_execution_finished = Signal(bool) def __init__(self, app_dir: str): super().__init__() self.app_dir = app_dir self.current_tab = "All" self.sort_by = "name" # Initialize settings manager settings_file = os.path.join(app_dir, SETTINGS_FILE) self.settings_manager = SettingsManager(settings_file) # 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 token = self.settings_manager.get_web_auth_token() host = "0.0.0.0" if self.settings_manager.get_web_allow_lan() else "127.0.0.1" self.web_server = WebServer( self.macro_manager, app_dir, DEFAULT_PORT, auth_token=token, host=host ) self.server_thread = None # Relay client (initialized later if enabled) self.relay_client = None # Setup UI self.setup_ui() self.setup_menu() self.setup_tray() # Start web server self.start_server() # Start relay client if enabled if self.settings_manager.get_relay_enabled(): self.start_relay_client() # Connect signals self.macros_changed.connect(self.refresh_macros) self.relay_session_received.connect(self._handle_relay_session) self.relay_connected_signal.connect(lambda: self._update_relay_status(True)) self.relay_disconnected_signal.connect(lambda: self._update_relay_status(False)) self.macro_execution_finished.connect(self._on_macro_execution_finished) # Debounce timer for resize-driven grid rebuilds. self._resize_timer = QTimer(self) self._resize_timer.setSingleShot(True) self._resize_timer.setInterval(150) self._resize_timer.timeout.connect(self._apply_resize) self._last_col_count = None # Load initial data self.refresh_tabs() self.refresh_macros() def setup_ui(self): """Setup the main UI components.""" self.setWindowTitle("MacroPad Server") 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: {THEME['accent_hover']}; }} """) add_btn.clicked.connect(self.add_macro) toolbar_layout.addWidget(add_btn) toolbar_layout.addStretch() # Clickable IP address label (click to copy) self.ip_label = QPushButton() self.ip_label.setCursor(Qt.PointingHandCursor) self.ip_label.setStyleSheet(f""" QPushButton {{ background-color: transparent; color: {THEME['fg_color']}; border: none; padding: 4px 8px; }} """) self.ip_label.clicked.connect(self.copy_url_to_clipboard) 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() # Windows startup option (only on Windows) if sys.platform == 'win32': self.startup_action = QAction("Start on Windows Startup", self) self.startup_action.setCheckable(True) self.startup_action.setChecked(self.get_startup_enabled()) self.startup_action.triggered.connect(self.toggle_startup) file_menu.addAction(self.startup_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) # Edit menu edit_menu = menubar.addMenu("Edit") settings_action = QAction("Settings...", self) settings_action.setShortcut("Ctrl+,") settings_action.triggered.connect(self.show_settings) edit_menu.addAction(settings_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 from bundled resources icon_path = get_resource_path("Macro Pad.png") if os.path.exists(icon_path): icon = QIcon(icon_path) self.tray_icon.setIcon(icon) self.setWindowIcon(icon) else: self.tray_icon.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon)) # Tray menu tray_menu = QMenu() show_action = tray_menu.addAction("Show") show_action.triggered.connect(self.restore_window) 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.ActivationReason.DoubleClick: self.restore_window() def restore_window(self): """Restore and bring window to front.""" # Clear minimized state and show self.setWindowState(Qt.WindowNoState) self.show() self.raise_() self.activateWindow() def start_server(self): """Start the web server in a background thread.""" self.server_error = None def run(): try: self.web_server.create_app() self.web_server.run() except Exception as e: self.server_error = str(e) # Emit signal to show error on main thread QTimer.singleShot(0, self.show_server_error) self.server_thread = threading.Thread(target=run, daemon=True) self.server_thread.start() # Give the server a moment to start, then check for errors QTimer.singleShot(1000, self.check_server_started) def check_server_started(self): """Check if server started successfully.""" if self.server_error: self.show_server_error() else: self.status_bar.showMessage(f"Server running on port {DEFAULT_PORT}") def show_server_error(self): """Show server error dialog.""" error_msg = self.server_error or "Unknown error" self.status_bar.showMessage(f"Server failed to start") QMessageBox.warning( self, "Web Server Error", f"Failed to start web server on port {DEFAULT_PORT}.\n\n" f"Error: {error_msg}\n\n" "The web interface will not be available.\n" "Check if another application is using the port." ) def stop_server(self): """Stop the web server.""" if self.web_server: self.web_server.stop() self.status_bar.showMessage("Server stopped") def update_ip_label(self): """Update the IP address label.""" # Check if relay is connected and has a session ID relay_connected = self.relay_client and self.relay_client.is_connected() session_id = self.settings_manager.get_relay_session_id() if relay_connected and session_id: relay_url = self.settings_manager.get_relay_url() # Convert wss:// to https:// for display base_url = relay_url.replace('wss://', 'https://').replace('ws://', 'http://') base_url = base_url.replace('/desktop', '').rstrip('/') full_url = f"{base_url}/{session_id}" self.ip_label.setText(full_url) return # Fall back to local IP. Append the auth token as a query param so the # QR/copied URL lets a LAN device authenticate against the API. token = self.settings_manager.get_web_auth_token() token_qs = f"?token={token}" if token else "" ip = self._detect_lan_ip() if ip: self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}{token_qs}") return self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}{token_qs}") @staticmethod def _detect_lan_ip(): """Best-effort primary LAN IPv4, with no third-party dependency. Uses a UDP socket to discover which local interface would be used to reach the internet (no packets are actually sent), then falls back to resolving the hostname. Returns None if only loopback is available. """ import socket try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(("8.8.8.8", 80)) ip = s.getsockname()[0] finally: s.close() if ip and not ip.startswith("127."): return ip except Exception: pass try: for ip in socket.gethostbyname_ex(socket.gethostname())[2]: if not ip.startswith("127."): return ip except Exception: pass return None def copy_url_to_clipboard(self): """Copy the web interface URL to clipboard.""" url = self.ip_label.text() if url == "Copied!": return # Already showing copied feedback clipboard = QApplication.clipboard() clipboard.setText(url) # Show "Copied!" feedback with dimmed style self.ip_label.setText("Copied!") self.ip_label.setStyleSheet(""" QPushButton { background-color: transparent; color: #888888; border: none; padding: 4px 8px; } """) # Restore original URL after delay QTimer.singleShot(1500, self._restore_url_label) def _restore_url_label(self): """Restore URL label after copy feedback.""" self.ip_label.setStyleSheet(f""" QPushButton {{ background-color: transparent; color: {THEME['fg_color']}; border: none; padding: 4px 8px; }} """) self.update_ip_label() 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 = self._compute_columns() self._last_col_count = cols for i, (macro_id, macro) in enumerate(filtered): btn = MacroButton(macro_id, macro, self.app_dir) btn.clicked.connect(lambda checked, mid=macro_id: self.execute_macro(mid)) btn.edit_requested.connect(self.edit_macro) btn.delete_requested.connect(self.delete_macro) 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 off the GUI thread so the UI doesn't freeze. pyautogui presses and wait steps are blocking; running them on the Qt main thread would freeze the window. Run in a worker thread and report the result back via a signal (thread-safe). """ self.status_bar.showMessage("Executing macro...", 2000) def run(): try: success = self.macro_manager.execute_macro(macro_id) except Exception: success = False self.macro_execution_finished.emit(success) threading.Thread(target=run, daemon=True).start() def _on_macro_execution_finished(self, success: bool): """Report macro execution result on the GUI thread.""" 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.""" macro = self.macro_manager.get_macro(macro_id) macro_name = macro.get("name", "") if macro else "" prompt = ( f"Are you sure you want to delete \"{macro_name}\"?" if macro_name else "Are you sure you want to delete this macro?" ) reply = QMessageBox.question( self, "Delete Macro", prompt, 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.""" about_box = QMessageBox(self) about_box.setWindowTitle("About MacroPad Server") about_box.setTextFormat(Qt.RichText) about_box.setTextInteractionFlags(Qt.TextBrowserInteraction) about_box.setText( f"

MacroPad Server v{VERSION}

" "

A cross-platform macro management application
" "with desktop and web interfaces.

" "

Author: ShadowDao

" "

Updates: shadowdao.com

" "

Donate: Liberapay

" ) about_box.setStandardButtons(QMessageBox.Ok) about_box.exec() def show_settings(self): """Show settings dialog.""" from .settings_dialog import SettingsDialog dialog = SettingsDialog(self.settings_manager, parent=self) dialog.relay_settings_changed.connect(self.on_relay_settings_changed) dialog.exec_() def on_relay_settings_changed(self): """Handle relay settings changes.""" # Stop existing relay client if running self.stop_relay_client() # Start new relay client if enabled if self.settings_manager.get_relay_enabled(): self.start_relay_client() # Update IP label to show relay URL if connected self.update_ip_label() def start_relay_client(self): """Start the relay client in a background thread.""" try: from relay_client import RelayClient except ImportError: self.status_bar.showMessage("Relay client not available", 3000) return url = self.settings_manager.get_relay_url() password = self.settings_manager.get_relay_password() session_id = self.settings_manager.get_relay_session_id() if not url or not password: self.status_bar.showMessage("Relay not configured", 3000) return self.relay_client = RelayClient( relay_url=url, password=password, session_id=session_id, local_port=DEFAULT_PORT, on_connected=self.on_relay_connected, on_disconnected=self.on_relay_disconnected, on_session_id=self.on_relay_session_id, local_auth_token=self.settings_manager.get_web_auth_token() ) self.relay_client.start() self.status_bar.showMessage("Connecting to relay server...") def stop_relay_client(self): """Stop the relay client.""" if self.relay_client: self.relay_client.stop() self.relay_client = None self.status_bar.showMessage("Relay disconnected", 2000) def on_relay_connected(self): """Handle relay connection established (called from background thread).""" self.relay_connected_signal.emit() def on_relay_disconnected(self): """Handle relay disconnection (called from background thread).""" self.relay_disconnected_signal.emit() def on_relay_session_id(self, session_id: str): """Handle receiving session ID from relay (called from background thread).""" self.relay_session_received.emit(session_id) def _handle_relay_session(self, session_id: str): """Handle relay session on main thread.""" if not self.settings_manager.set_relay_session_id(session_id): self.status_bar.showMessage("Warning: failed to save relay session", 3000) self.update_ip_label() def _update_relay_status(self, connected: bool): """Update UI for relay status (called on main thread).""" if connected: self.status_bar.showMessage("Connected to relay server") else: self.status_bar.showMessage("Relay disconnected - reconnecting...") self.update_ip_label() # Windows startup management def get_startup_enabled(self) -> bool: """Check if app is set to start on Windows startup.""" if sys.platform != 'win32': return False try: key = winreg.OpenKey( winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_READ ) try: winreg.QueryValueEx(key, "MacroPad Server") return True except FileNotFoundError: return False finally: winreg.CloseKey(key) except Exception: return False def set_startup_enabled(self, enabled: bool): """Enable or disable starting on Windows startup.""" if sys.platform != 'win32': return try: key = winreg.OpenKey( winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_SET_VALUE ) try: if enabled: # Get the executable path if getattr(sys, 'frozen', False): # Running as compiled executable exe_path = sys.executable else: # Running as script - use pythonw to avoid console exe_path = f'"{sys.executable}" "{os.path.abspath(sys.argv[0])}"' winreg.SetValueEx(key, "MacroPad Server", 0, winreg.REG_SZ, exe_path) self.status_bar.showMessage("Added to Windows startup", 3000) else: try: winreg.DeleteValue(key, "MacroPad Server") self.status_bar.showMessage("Removed from Windows startup", 3000) except FileNotFoundError: pass finally: winreg.CloseKey(key) except Exception as e: QMessageBox.warning(self, "Error", f"Failed to update startup settings: {e}") def toggle_startup(self): """Toggle the startup setting.""" current = self.get_startup_enabled() self.set_startup_enabled(not current) # Update menu checkmark if hasattr(self, 'startup_action'): self.startup_action.setChecked(not current) def closeEvent(self, event): """Handle window close.""" # Stop the relay client self.stop_relay_client() # Stop the web server self.stop_server() # Hide tray icon self.tray_icon.hide() event.accept() def _compute_columns(self) -> int: """Compute how many macro columns fit at the current width.""" return max(1, (self.width() - 40) // 130) def _apply_resize(self): """Rebuild the grid only if the column count actually changed.""" if self._compute_columns() != self._last_col_count: self.refresh_macros() def resizeEvent(self, event): """Handle window resize (debounced to coalesce rapid resize events).""" super().resizeEvent(event) # Coalesce a burst of resize events; only rebuild after things settle # and only when the computed column count changed. self._resize_timer.start() def changeEvent(self, event): """Handle window state changes - minimize to tray.""" if event.type() == QEvent.Type.WindowStateChange: if (self.windowState() & Qt.WindowMinimized and self.settings_manager.get_minimize_to_tray()): # Hide instead of minimize (goes to tray) event.ignore() self.hide() self.tray_icon.showMessage( "MacroPad Server", "Running in system tray. Double-click to restore.", QSystemTrayIcon.Information, 2000 ) return super().changeEvent(event)