import os import json import sys import tkinter as tk from tkinter import filedialog, messagebox from PIL import Image, ImageTk import numpy as np from threading import Thread, Lock import folder_paths import io import time import threading # Global Shutdown Registry class ShutdownRegistry: """Global registry to handle shutdown without atexit""" _instance = None _nodes = [] _shutdown_called = False def __new__(cls): if cls._instance is None: cls._instance = super(ShutdownRegistry, cls).__new__(cls) return cls._instance def register(self, node): """Register a node for cleanup""" if not self._shutdown_called: self._nodes.append(node) def shutdown_all(self): """Shutdown all registered nodes""" if self._shutdown_called: return self._shutdown_called = True wp_logger.info(f"Cleaning up {len(self._nodes)} nodes...", "ShutdownRegistry") for node in self._nodes[:]: try: if hasattr(node, 'cleanup'): node.cleanup() except Exception as e: wp_logger.error(f"Error in cleanup: {e}", "ShutdownRegistry") self._nodes.clear() # Create global instance shutdown_registry = ShutdownRegistry() # Logging Structured class WatchPointLogger: """Structured logging system for WatchPoint""" def __init__(self): self.enabled = True self.log_level = "INFO" # DEBUG, INFO, WARNING, ERROR self.logs = [] self.max_logs = 100 self.debug_mode = False self.debug_log_dir = "debug_logs" self.debug_session_id = None self.config_file = os.path.join(os.path.dirname(__file__), "watchpoint_debug_config.json") self.load_debug_config() def log(self, level, message, component="WatchPoint"): """Log a message with level and component""" if not self.enabled: return # Check log level levels = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3} if levels.get(level, 1) < levels.get(self.log_level, 1): return timestamp = time.strftime("%Y-%m-%d %H:%M:%S") log_entry = { "timestamp": timestamp, "level": level, "component": component, "message": message } self.logs.append(log_entry) # Keep only the latest logs if len(self.logs) > self.max_logs: self.logs.pop(0) # Always print errors if level == "ERROR": print(f"WatchPoint [{timestamp}] {level}: {message}") elif level == "WARNING": print(f"WatchPoint [{timestamp}] {level}: {message}") def debug(self, message, component="WatchPoint"): self.log("DEBUG", message, component) def info(self, message, component="WatchPoint"): self.log("INFO", message, component) def warning(self, message, component="WatchPoint"): self.log("WARNING", message, component) def error(self, message, component="WatchPoint"): self.log("ERROR", message, component) def get_logs(self, level=None, component=None): """Get filtered logs by level and component""" filtered_logs = self.logs if level: filtered_logs = [log for log in filtered_logs if log["level"] == level] if component: filtered_logs = [log for log in filtered_logs if log["component"] == component] return filtered_logs def clear_logs(self): """Clear all logs""" self.logs = [] def set_debug_mode(self, enabled=True): """Enable/Disable debug mode""" self.debug_mode = enabled if enabled: self.debug_session_id = time.strftime("%Y%m%d_%H%M%S") os.makedirs(self.debug_log_dir, exist_ok=True) self.info(f"Debug mode enabled - Session: {self.debug_session_id}", "Debug") else: self.info("Debug mode disabled", "Debug") self.save_debug_config() def load_debug_config(self): """Load debug configuration from file""" try: if os.path.exists(self.config_file): with open(self.config_file, 'r') as f: config = json.load(f) self.debug_mode = config.get('debug_mode', False) if self.debug_mode: self.debug_session_id = time.strftime("%Y%m%d_%H%M%S") self.info(f"Persistent debug mode activated - Session: {self.debug_session_id}", "Debug") except Exception as e: self.error(f"Error loading debug config: {e}", "Debug") def save_debug_config(self): """Save debug configuration to file""" try: config = { 'debug_mode': self.debug_mode, 'timestamp': time.strftime("%Y-%m-%d %H:%M:%S") } with open(self.config_file, 'w') as f: json.dump(config, f, indent=2) except Exception as e: self.error(f"Error saving debug config: {e}", "Debug") def save_debug_dump(self, watch_point_instance=None): """Save complete debug dump with stats, threads, and logs""" if not self.debug_mode: return # Prevent infinite recursion if hasattr(self, '_saving_dump') and self._saving_dump: return None try: self._saving_dump = True timestamp = time.strftime("%Y%m%d_%H%M%S") filename = f"debug_dump_{self.debug_session_id}_{timestamp}.log" filepath = os.path.join(self.debug_log_dir, filename) debug_data = { "timestamp": timestamp, "session_id": self.debug_session_id, "system_info": { "python_version": sys.version, "platform": sys.platform, "threading_active": threading.active_count() } } if watch_point_instance and hasattr(watch_point_instance, 'get_health_stats'): debug_data["health_stats"] = watch_point_instance.get_health_stats() if watch_point_instance and hasattr(watch_point_instance, 'debug_threads'): # Call the function directly without going through the logger to avoid recursion debug_data["thread_info"] = watch_point_instance.debug_threads() debug_data["recent_logs"] = self.get_logs()[-50:] # Last 50 logs # Save to file with open(filepath, 'w', encoding='utf-8') as f: import json json.dump(debug_data, f, indent=2, default=str) self.info(f"Debug dump saved: {filename}", "Debug") return filepath except Exception as e: self.error(f"Error saving debug dump: {e}", "Debug") return None finally: # Clear recursion flag self._saving_dump = False # Create global logger wp_logger = WatchPointLogger() try: import win32clipboard import win32con WIN32_AVAILABLE = True except ImportError: WIN32_AVAILABLE = False try: from screeninfo import get_monitors SCREENINFO_AVAILABLE = True except ImportError: SCREENINFO_AVAILABLE = False # Settings Management class SettingsManager: """Handles loading and saving of settings to a JSON file.""" def __init__(self, settings_file): self.filepath = settings_file self.defaults = { "window_width": 800, "window_height": 600, "window_x": None, "window_y": None, "window_size_mode": "fixed", "show_toolbar": True, "save_format": "png", "jpeg_quality": 90, } self.settings = self.load() def load(self): """Loads settings from the file, merging with defaults.""" try: if os.path.exists(self.filepath): with open(self.filepath, 'r') as f: return {**self.defaults, **json.load(f)} except Exception as e: print(f"Watch Point: Error loading settings, using defaults. Error: {e}") return self.defaults.copy() def save(self): """Saves the current settings to the file.""" try: with open(self.filepath, 'w') as f: json.dump(self.settings, f, indent=2) except Exception as e: print(f"Watch Point: Could not save settings. Error: {e}") def get(self, key, default=None): """Gets a setting value by key.""" return self.settings.get(key, default) def set(self, key, value): """Sets a setting value.""" self.settings[key] = value # Window Management class WindowManager: """Manages the lifecycle and state of all Tkinter preview windows.""" _instance = None def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super(WindowManager, cls).__new__(cls) return cls._instance def __init__(self, settings_manager=None): if not hasattr(self, 'initialized'): self.windows = {} self.settings_manager = settings_manager or SettingsManager( os.path.join(os.path.dirname(__file__), "watchpoint_settings.json") ) self.shutdown_event = threading.Event() self._start_time = time.time() # Start time for statistics self.initialized = True # Iniciar watchdog self.watchdog_thread = Thread(target=self._watchdog_loop, daemon=True) self.watchdog_thread.start() wp_logger.info("WindowManager initialized", "WindowManager") def show_image(self, display_idx, pil_img, text=None): """Creates or updates THE SINGLE global window to display an image and optional text.""" # Check if any window exists, reuse it if len(self.windows) > 0: # Get the index of the existing window (there should only be 1) existing_idx = list(self.windows.keys())[0] win_data = self.windows[existing_idx] # Check that the window is running if not win_data.get("running", False): # Window exists but is dead, clean up wp_logger.warning(f"Window {existing_idx} exists but is not running, cleaning up", "ShowImage") self._cleanup_window(existing_idx) # Continue to create new window (below) else: # Window is alive, REUSE wp_logger.debug(f"Reusing existing window {existing_idx} for new image", "ShowImage") # Update image with win_data["lock"]: win_data["image"] = pil_img # Update text if it exists if text and win_data.get("instance"): win_data["instance"].update_signal_text(text) # Update title if monitor changed if existing_idx != display_idx: # Monitor changed, update title wp_logger.info(f"Monitor changed from {existing_idx} to {display_idx} (only updates title)", "ShowImage") instance = win_data.get("instance") if instance and hasattr(instance, 'root'): try: # Update simplified title instance.root.title("Watch Point") except Exception as e: wp_logger.warning(f"Could not update title: {e}", "ShowImage") return # If we reach here, NO window exists, create a new one wp_logger.info(f"Creating new global window on Monitor {display_idx}", "ShowImage") lock = Lock() win_data = { "image": pil_img, "lock": lock, "running": True, "instance": None, "pending_text": text, "minimized": False } self.windows[display_idx] = win_data thread = Thread(target=self._window_loop, args=(display_idx,), daemon=True) win_data["thread"] = thread thread.start() def hide_window(self, display_idx): """Signals a window to close with timeout garantizado.""" if display_idx in self.windows: self.windows[display_idx]["running"] = False self.windows[display_idx]["closing"] = True self.windows[display_idx]["close_started"] = time.time() try: thread = self.windows[display_idx].get("thread") if thread and thread.is_alive(): thread.join(timeout=3.0) wp_logger.debug(f"Thread {display_idx} finished successfully", "HideWindow") except Exception as e: wp_logger.error(f"Error waiting for thread {display_idx}: {e}", "HideWindow") # Force cleanup if thread is still alive if display_idx in self.windows and self.windows[display_idx].get("thread", {}).is_alive(): wp_logger.warning(f"Forcing cleanup for window {display_idx}", "HideWindow") self._force_cleanup_window(display_idx) def update_all_text(self, text): """Updates the text in all currently open windows.""" for win_data in self.windows.values(): if win_data.get("running") and win_data.get("instance"): win_data["instance"].update_signal_text(text) def _window_loop(self, display_idx): """The main loop for a Tkinter window thread with robust error handling.""" root = None win_instance = None try: # Create main window root = tk.Tk() root.title("Watch Point") self._apply_icon(root) self._apply_geometry(root, display_idx) win_instance = WatchPointWindow(root, display_idx, self) self.windows[display_idx]["instance"] = win_instance # NEW: Close handler that minimizes instead of closing - Accidental close protection! def safe_close(): # Instead of closing, MINIMIZE the window to the taskbar! try: root.iconify() # Minimize to taskbar # Update state: window is still alive but minimized self.windows[display_idx]["minimized"] = True self.windows[display_idx]["running"] = True # Still running wp_logger.info(f"Window {display_idx} minimized (protected from accidental close)", "WindowLoop") except Exception as e: wp_logger.warning(f"Error minimizing window {display_idx}: {e}", "WindowLoop") # Fallback: if cannot minimize, try to hide try: root.withdraw() self.windows[display_idx]["minimized"] = True wp_logger.info(f"Window {display_idx} hidden as fallback", "WindowLoop") except: pass root.protocol("WM_DELETE_WINDOW", safe_close) # IMPROVED: Better mainloop exception handling try: root.mainloop() except RuntimeError as e: # Common RuntimeError when closing: "main thread is not in main loop" if "main thread" not in str(e): wp_logger.error(f"RuntimeError in mainloop: {e}", "WindowLoop") # Trigger debug dump on critical error if wp_logger.debug_mode: wp_logger.save_debug_dump() raise else: wp_logger.debug(f"Expected RuntimeError in mainloop: {e}", "WindowLoop") except Exception as e: # Trigger debug dump on thread errors (Tcl_AsyncDelete) if wp_logger.debug_mode and ("Tcl_AsyncDelete" in str(e) or "async handler" in str(e)): wp_logger.save_debug_dump() wp_logger.error(f"Error in Tkinter mainloop: {e}", "WindowLoop") except Exception as e: wp_logger.error(f"Error in window {display_idx}: {e}", "WindowLoop") # Trigger debug dump on critical window error if wp_logger.debug_mode: wp_logger.save_debug_dump() finally: # IMPROVED: Cleanup with multiple attempts and Tkinter cleanup # CRITICAL: Only the main thread can touch Tkinter resources import threading current_thread = threading.current_thread() for attempt in range(3): try: # TCL PROTECTION: Only clean Tkinter if we're in the main thread if current_thread.name == "MainThread": # Clean Tkinter resources first if win_instance: try: win_instance.cleanup_tkinter_resources() except Exception as e: wp_logger.warning(f"Error cleaning up Tkinter resources: {e}", "WindowLoop") if root: try: root.quit() except: pass try: root.destroy() except: pass else: # If not in main thread, just log and skip Tkinter cleanup wp_logger.warning(f"Skipping Tkinter cleanup from thread {current_thread.name} - Tcl_AsyncDelete protection", "WindowLoop") continue # If in main thread, try cleanup self._cleanup_window(display_idx) break except Exception as e: if attempt == 2: wp_logger.error(f"Cleanup failed after 3 attempts: {e}", "WindowLoop") time.sleep(0.05) def restore_window(self, display_idx): """Restore a minimized window - To recover it from the taskbar!""" if display_idx in self.windows: win_data = self.windows[display_idx] # Check if it's minimized if not win_data.get("minimized", False): wp_logger.debug(f"Window {display_idx} is not minimized", "RestoreWindow") return False instance = win_data.get("instance") if not instance: wp_logger.warning(f"Cannot restore window {display_idx}: invalid instance", "RestoreWindow") return False try: # Restore the window using the instance's root if hasattr(instance, 'root') and instance.root: instance.root.deiconify() # Make visible again instance.root.lift() # Bring to front instance.root.focus_force() # Give focus # Update state win_data["minimized"] = False wp_logger.info(f"Window {display_idx} restored successfully", "RestoreWindow") return True except Exception as e: wp_logger.error(f"Error restoring window {display_idx}: {e}", "RestoreWindow") return False else: wp_logger.warning(f"Window {display_idx} does not exist", "RestoreWindow") return False def _apply_icon(self, root): """Applies the icon from a file to the window.""" try: script_dir = os.path.dirname(os.path.abspath(__file__)) icon_path = os.path.join(script_dir, "preview_monitor_icon.png") if os.path.exists(icon_path): icon_img = tk.PhotoImage(file=icon_path) root.iconphoto(True, icon_img) except tk.TclError: print("Watch Point: Could not apply icon. Ensure it's a valid PNG/GIF.") def _apply_geometry(self, root, display_idx): """Calculates and applies the window's initial size and position.""" size_mode = self.settings_manager.get("window_size_mode") width = self.settings_manager.get("window_width") height = self.settings_manager.get("window_height") geom_str = self.calculate_geometry_string(root, size_mode, width, height) if geom_str: root.geometry(geom_str) # Prioritize monitor selection, then fallback to saved position position_set = False if SCREENINFO_AVAILABLE: try: m = get_monitors()[display_idx] root.geometry(f"+{m.x + 50}+{m.y + 50}") position_set = True except IndexError: print(f"Watch Point: Monitor index {display_idx} is out of range. Falling back.") if not position_set and self.settings_manager.get("use_last_known_position", False): x = self.settings_manager.get("window_x") y = self.settings_manager.get("window_y") if x is not None and y is not None: root.geometry(f"+{x}+{y}") def calculate_geometry_string(self, root, size_mode, default_w, default_h): """Returns a geometry string (e.g., '800x600') based on the size mode.""" try: if size_mode in ["Half Vertical", "Half Horizontal", "Quarter"]: screen_w = root.winfo_screenwidth() screen_h = root.winfo_screenheight() if size_mode == "Half Vertical": return f"{screen_w // 2}x{screen_h - 100}" if size_mode == "Half Horizontal": return f"{screen_w}x{screen_h // 2 - 50}" if size_mode == "Quarter": return f"{screen_w // 2}x{screen_h // 2}" elif 'x' in str(size_mode): # Handles "800x600" etc. return size_mode except Exception as e: print(f"Watch Point: Error calculating geometry: {e}") return f"{default_w}x{default_h}" def _cleanup_window(self, display_idx): """Ensures a window and its resources are properly removed with multiple attempts.""" if display_idx in self.windows: import threading current_thread = threading.current_thread() for attempt in range(3): try: win_data = self.windows[display_idx] # THREAD PROTECTION: Only try to join if current thread is NOT the window thread if "thread" in win_data and win_data["thread"].is_alive(): # Verificar que no estemos intentando hacer join a nosotros mismos if win_data["thread"] != current_thread: try: win_data["thread"].join(timeout=0.1) except Exception: pass else: wp_logger.warning(f"Avoiding self-join in cleanup for window {display_idx}", "Cleanup") # Remove from list ALWAYS, even if errors occur try: del self.windows[display_idx] except KeyError: # Already removed, not an error pass # Log success after successful cleanup wp_logger.debug(f"Successfully cleaned up window {display_idx} (attempt {attempt + 1})", "Cleanup") break except Exception as e: if attempt == 2: wp_logger.error(f"Cleanup failed after 3 attempts: {e}", "Cleanup") time.sleep(0.05) def _watchdog_loop(self): """Monitor and clean up dead threads every second""" wp_logger.info("Watchdog started", "Watchdog") while not self.shutdown_event.is_set(): time.sleep(1.0) dead_windows = [] for display_idx, win_data in list(self.windows.items()): # Detect dead threads that didn't clean up if "thread" in win_data: if not win_data["thread"].is_alive(): dead_windows.append(display_idx) wp_logger.warning(f"Dead thread detected for window {display_idx}", "Watchdog") # Detect windows that are taking too long to close elif win_data.get("closing") and win_data.get("close_started"): if time.time() - win_data["close_started"] > 5.0: # Force cleanup after 5 seconds dead_windows.append(display_idx) wp_logger.warning(f"Window {display_idx} taking too long to close", "Watchdog") # Clean up dead windows for idx in dead_windows: # Log before force cleanup wp_logger.info(f"Force cleaning up dead window {idx}", "Watchdog") self._force_cleanup_window(idx) wp_logger.info("Watchdog finished", "Watchdog") def _force_cleanup_window(self, display_idx): """Force cleanup of a window without waiting for the thread""" if display_idx in self.windows: try: # No intentar join, solo eliminar del self.windows[display_idx] # Log success after successful force cleanup wp_logger.debug(f"Successfully force cleaned up window {display_idx}", "Cleanup") except Exception as e: wp_logger.error(f"Error in force cleanup: {e}", "Cleanup") def get_health_stats(self): """Get health statistics of the system""" stats = { "total_windows_created": len(self.windows), "active_windows": len([w for w in self.windows.values() if w.get("running", False)]), "closing_windows": len([w for w in self.windows.values() if w.get("closing", False)]), "threads_alive": len([w for w in self.windows.values() if w.get("thread") and w["thread"].is_alive()]), "watchdog_status": "running" if hasattr(self, 'watchdog_thread') and self.watchdog_thread.is_alive() else "stopped", "shutdown_event": self.shutdown_event.is_set(), "uptime": time.time() - getattr(self, '_start_time', time.time()) } # Add thread information import threading stats["total_threads"] = threading.active_count() return stats def shutdown(self): """Shutdown global del WindowManager without using atexit""" wp_logger.info("Initiating global shutdown...", "WindowManager") self.shutdown_event.set() # Close all active windows for display_idx in list(self.windows.keys()): try: self.hide_window(display_idx) except Exception as e: # Log error if window closing fails wp_logger.error(f"Error closing window {display_idx}: {e}", "WindowManager") # Wait for the watchdog thread to finish try: if hasattr(self, 'watchdog_thread') and self.watchdog_thread.is_alive(): self.watchdog_thread.join(timeout=2.0) except Exception as e: # Log error if watchdog thread join fails wp_logger.error(f"Error waiting for watchdog thread: {e}", "WindowManager") # Log successful shutdown completion wp_logger.info("Global shutdown completed successfully", "WindowManager") # Global Window Manager Instance window_manager = WindowManager() # Main ComfyUI Node class WatchPoint: """The main ComfyUI node class.""" def __init__(self): self.window_manager = window_manager self.last_display_idx = -1 # Register for cleanup shutdown_registry.register(self) @classmethod def INPUT_TYPES(cls): monitors = ["Monitor 0 (Default)"] if SCREENINFO_AVAILABLE: try: monitors = [f"Monitor {i} ({m.width}x{m.height})" for i, m in enumerate(get_monitors())] except Exception: pass return { "required": { "images": ("IMAGE",), "floating_preview": ("BOOLEAN", {"default": True}), "monitor_preview": ("BOOLEAN", {"default": True}), "monitor": (monitors, {"default": monitors[0]}), }, "optional": {"opt_signal_text": ("STRING", {"forceInput": True, "multiline": True})} } RETURN_TYPES = ("IMAGE",) FUNCTION = "watch" OUTPUT_NODE = True CATEGORY = "WatchPoint" def watch(self, images, floating_preview=True, monitor_preview=True, monitor="Monitor 0", opt_signal_text=None): try: display_idx = int(monitor.split(" ")[1]) except (ValueError, IndexError): display_idx = 0 self.last_display_idx = display_idx if monitor_preview: wp_logger.debug(f"Showing preview on monitor {display_idx}", "WatchPoint") image = 255.0 * images[0].cpu().numpy() pil_img = Image.fromarray(np.clip(image, 0, 255).astype(np.uint8)) self.window_manager.show_image(display_idx, pil_img, opt_signal_text) else: wp_logger.debug(f"Hiding preview on monitor {display_idx}", "WatchPoint") self.window_manager.hide_window(display_idx) ui_images = self._prepare_preview(images) if floating_preview else [] # NEW: Automatically save dump if debug is enabled if wp_logger.debug_mode: wp_logger.info("Saving automatic dump - WatchPoint executed", "WatchPoint") wp_logger.save_debug_dump(self) return {"ui": {"images": ui_images}, "result": (images,)} def _prepare_preview(self, images): """Creates temporary files for ComfyUI's floating preview.""" import time output_dir = folder_paths.get_temp_directory() results = [] for i, tensor in enumerate(images): array = 255.0 * tensor.cpu().numpy() img = Image.fromarray(np.clip(array, 0, 255).astype(np.uint8)) ts = int(time.time() * 1000) filename = f"watchpoint_{ts}_{i}.png" img.save(os.path.join(output_dir, filename), compress_level=4) results.append({"filename": filename, "subfolder": "", "type": "temp"}) return results def cleanup(self): """Cleanup del nodo WatchPoint sin usar atexit""" if hasattr(self, 'window_manager'): self.window_manager.shutdown() def get_logs(self, level=None, component=None): """Get system logging logs""" return wp_logger.get_logs(level, component) def clear_logs(self): """Clear all logs""" wp_logger.clear_logs() wp_logger.info("Logs cleared", "WatchPoint") def set_debug_mode(self, enabled=True): """Enable/Disable debug mode""" wp_logger.set_debug_mode(enabled) if enabled: wp_logger.info("Debug mode enabled for WatchPoint", "WatchPoint") else: wp_logger.info("Debug mode disabled for WatchPoint", "WatchPoint") def save_debug_dump(self): """Save complete debug dump""" filepath = wp_logger.save_debug_dump(self) if filepath: wp_logger.info(f"Debug dump saved at: {filepath}", "WatchPoint") return filepath def get_health_stats(self): """Get system health statistics""" return self.window_manager.get_health_stats() def debug_threads(self): """Debug command to inspect threads""" import threading debug_info = { "total_threads": threading.active_count(), "threads": [], "window_threads": [], "daemon_threads": [] } # Inspect all threads for thread in threading.enumerate(): thread_info = { "name": thread.name, "ident": thread.ident, "is_alive": thread.is_alive(), "is_daemon": thread.daemon, "thread_type": type(thread).__name__ } debug_info["threads"].append(thread_info) if thread.daemon: debug_info["daemon_threads"].append(thread_info) # Inspect window threads if hasattr(self, 'window_manager') and hasattr(self.window_manager, 'windows'): for window_id, window_data in self.window_manager.windows.items(): if window_data.get('thread'): thread = window_data['thread'] window_thread_info = { "window_id": window_id, "thread_name": thread.name, "thread_ident": thread.ident, "is_alive": thread.is_alive(), "running": window_data.get('running', False), "closing": window_data.get('closing', False) } debug_info["window_threads"].append(window_thread_info) # Add watchdog information if hasattr(self.window_manager, 'watchdog_thread'): watchdog = self.window_manager.watchdog_thread debug_info["watchdog"] = { "name": watchdog.name, "ident": watchdog.ident, "is_alive": watchdog.is_alive(), "is_daemon": watchdog.daemon } wp_logger.info(f"Debug threads executed: {debug_info['total_threads']} active threads, " f"{len(debug_info['window_threads'])} window threads, " f"{len(debug_info['daemon_threads'])} daemon threads", "WatchPoint") return debug_info # Tkinter UI Classes class WatchPointWindow: """The main UI for a preview window.""" def __init__(self, root, display_idx, manager): self.root = root self.display_idx = display_idx self.manager = manager self.settings = manager.settings_manager # UI State self.zoom_level, self.pan_x, self.pan_y = 1.0, 0, 0 self.is_dragging, self.drag_start_x, self.drag_start_y = False, 0, 0 self.zoom_1to1_active = False self.drawer_visible = True self.toolbar_visible = self.settings.get("show_toolbar", True) self.current_pil_image, self.photo_image = None, None self.size_var = tk.StringVar() self._create_ui() self._bind_events() self._set_initial_state() self._update_image_loop() def cleanup_tkinter_resources(self): """Safe cleanup of Tkinter resources to prevent destruction errors""" # TCL PROTECTION: Only execute in main thread import threading current_thread = threading.current_thread() if current_thread.name != "MainThread": wp_logger.warning(f"Skipping cleanup_tkinter_resources from thread {current_thread.name} - Tcl_AsyncDelete protection", "WatchPointWindow") return try: # Clear Tkinter variables if hasattr(self, 'size_var'): try: self.size_var.set('') # Clear value before destroying except: pass self.size_var = None # Clear images if hasattr(self, 'photo_image') and self.photo_image: try: self.photo_image = None except: pass # Clean up canvas if hasattr(self, 'canvas') and self.canvas: try: self.canvas.delete("all") except: pass # Log successful cleanup wp_logger.debug(f"Successfully cleaned up Tkinter resources for window {self.display_idx}", "WatchPointWindow") except Exception as e: wp_logger.warning(f"Error cleaning up Tkinter resources: {e}", "WatchPointWindow") def _create_ui(self): # Drawer (Signal Scout) self.drawer_frame = tk.Frame(self.root, bg="#1c1c1c", width=300) self.drawer_frame.pack(side="right", fill="y") self.drawer_frame.pack_propagate(False) tk.Label(self.drawer_frame, text="PROMPT", bg="#1c1c1c", fg="#666", font=("Arial", 8, "bold"), pady=5).pack(fill="x") self.signal_text = tk.Text(self.drawer_frame, bg="#1c1c1c", fg="#00ff99", insertbackground="white", font=("Consolas", 10), wrap="word", padx=10, pady=10, borderwidth=0, highlightthickness=0) self.signal_text.pack(fill="both", expand=True) self.signal_text.insert("1.0", "Waiting for prompt...") self.signal_text.config(state="disabled") # Main Area self.main_frame = tk.Frame(self.root, bg='#1a1a1a') self.main_frame.pack(fill=tk.BOTH, expand=True) self._create_toolbar() self.canvas = tk.Canvas(self.main_frame, bg='#000000', highlightthickness=0) self.canvas.pack(fill=tk.BOTH, expand=True) self._create_context_menu() def _create_toolbar(self): self.toolbar = tk.Frame(self.main_frame, bg='#2a2a2a', height=40) btn_style = {'bg': '#3a3a3a', 'fg': 'white', 'relief': tk.FLAT, 'padx': 8, 'pady': 4} for text, cmd in [("โ†ป Reset", self._reset_zoom), ("โŠ• Zoom In", self._zoom_in), ("โŠ– Zoom Out", self._zoom_out), ("1:1", self._zoom_1to1)]: tk.Button(self.toolbar, text=text, command=cmd, **btn_style).pack(side=tk.LEFT, padx=2) size_opts = ["800x600", "1024x768", "1920x1080", "Half Vertical", "Half Horizontal", "Quarter"] size_menu = tk.OptionMenu(self.toolbar, self.size_var, *size_opts, command=self._on_size_change) size_menu.config(bg='#3a3a3a', fg='white', relief=tk.FLAT) size_menu.pack(side=tk.LEFT, padx=2) tk.Button(self.toolbar, text="โš™", command=self._open_settings, **btn_style).pack(side=tk.LEFT, padx=2) if self.toolbar_visible: self.toolbar.pack(side=tk.TOP, fill=tk.X) def _create_context_menu(self): self.context_menu = tk.Menu(self.root, tearoff=0) self.context_menu.add_command(label="Save Image As...", command=self._save_image) if WIN32_AVAILABLE: self.context_menu.add_command(label="Copy Image to Clipboard", command=self._copy_to_clipboard) self.context_menu.add_separator() self.context_menu.add_command(label="Toggle Drawer", command=self._toggle_drawer) def _copy_to_clipboard(self): """Copies the current image to the system clipboard.""" if not self.current_pil_image or not WIN32_AVAILABLE: return try: output = io.BytesIO() # Convert to RGB for BMP format, as RGBA might not be supported. self.current_pil_image.convert("RGB").save(output, "BMP") data = output.getvalue()[14:] # The BMP header is 14 bytes output.close() win32clipboard.OpenClipboard() win32clipboard.EmptyClipboard() win32clipboard.SetClipboardData(win32con.CF_DIB, data) win32clipboard.CloseClipboard() except Exception as e: # This will silently fail, as requested. print(f"Watch Point: Could not copy to clipboard. Error: {e}") def _bind_events(self): self.canvas.bind("", self._on_mouse_down) self.canvas.bind("", self._on_mouse_drag) self.canvas.bind("", self._on_mouse_up) self.canvas.bind("", self._on_mouse_wheel) self.canvas.bind("", self._show_context_menu) self.root.bind("", lambda e: self._reset_zoom()) self.root.bind("", lambda e: self._toggle_toolbar()) self.root.bind("

", lambda e: self._toggle_drawer()) self.root.protocol("WM_DELETE_WINDOW", self._on_close) def _set_initial_state(self): # Set size dropdown size_mode = self.settings.get("window_size_mode") if size_mode in ["Half Vertical", "Half Horizontal", "Quarter"]: self.size_var.set(size_mode) else: self.size_var.set(f'{self.settings.get("window_width")}x{self.settings.get("window_height")}') # Process pending text win_data = self.manager.windows.get(self.display_idx, {}) if win_data.get("pending_text"): self.update_signal_text(win_data["pending_text"]) win_data["pending_text"] = None def _update_image_loop(self): win_data = self.manager.windows.get(self.display_idx) if not win_data or not win_data.get("running"): try: self.root.quit() except tk.TclError: pass return with win_data["lock"]: pil_img = win_data.get("image") if pil_img and pil_img != self.current_pil_image: self.current_pil_image = pil_img self._render_image() self.root.after(33, self._update_image_loop) def _render_image(self): if not self.current_pil_image: return cw, ch = self.canvas.winfo_width(), self.canvas.winfo_height() if cw <= 1: return self.root.after(50, self._render_image) iw, ih = self.current_pil_image.size scale = min(cw/iw, ch/ih) * self.zoom_level if not self.zoom_1to1_active else 1.0 nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale)) resized = self.current_pil_image.resize((nw, nh), Image.LANCZOS) xp, yp = (cw - nw)//2 + self.pan_x, (ch - nh)//2 + self.pan_y self.photo_image = ImageTk.PhotoImage(resized) self.canvas.delete("all") self.canvas.create_image(xp, yp, anchor=tk.NW, image=self.photo_image) def update_signal_text(self, text): def _update(): self.signal_text.config(state="normal") self.signal_text.delete("1.0", tk.END) self.signal_text.insert("1.0", str(text)) self.signal_text.config(state="disabled") if self.root.winfo_exists(): self.root.after(0, _update) # Event Handlers def _on_size_change(self, size_str): geom_str = self.manager.calculate_geometry_string(self.root, size_str, 800, 600) if geom_str: self.root.geometry(geom_str) def _on_close(self): # Persist final window state self.settings.set("window_size_mode", self.size_var.get()) self.settings.set("window_x", self.root.winfo_x()) self.settings.set("window_y", self.root.winfo_y()) self.settings.set("show_toolbar", self.toolbar_visible) if 'x' in self.size_var.get(): try: w, h = map(int, self.size_var.get().split('x')) self.settings.set("window_width", w) self.settings.set("window_height", h) except ValueError: pass self.settings.save() # Signal manager to close self.manager.hide_window(self.display_idx) try: self.root.destroy() except tk.TclError: pass def _toggle_toolbar(self): self.toolbar_visible = not self.toolbar_visible if self.toolbar_visible: self.toolbar.pack(side=tk.TOP, fill=tk.X, before=self.canvas) else: self.toolbar.pack_forget() def _toggle_drawer(self): self.drawer_visible = not self.drawer_visible if self.drawer_visible: self.drawer_frame.pack(side="right", fill="y", before=self.main_frame) else: self.drawer_frame.pack_forget() def _save_image(self): if not self.current_pil_image: return fmt = self.settings.get("save_format", "png") ext = f".{fmt}" filetypes = [(fmt.upper(), f"*{ext}")] if fmt == "jpeg": filetypes.append(("All files", "*.*")) f_path = filedialog.asksaveasfilename(defaultextension=ext, filetypes=filetypes) if f_path: try: if fmt == "jpeg": self.current_pil_image.convert("RGB").save(f_path, quality=self.settings.get("jpeg_quality", 90)) else: self.current_pil_image.save(f_path) except Exception as e: messagebox.showerror("Save Error", f"Could not save image:\n{e}") def _open_settings(self): WatchPointSettingsDialog(self.root, self.settings) def _on_mouse_wheel(self, e): self._zoom_in() if e.delta > 0 else self._zoom_out() def _on_mouse_down(self, e): self.is_dragging, self.drag_start_x, self.drag_start_y = True, e.x, e.y def _on_mouse_up(self, e): self.is_dragging = False def _show_context_menu(self, e): self.context_menu.tk_popup(e.x_root, e.y_root) def _on_mouse_drag(self, e): if self.is_dragging: self.pan_x += e.x - self.drag_start_x self.pan_y += e.y - self.drag_start_y self.drag_start_x, self.drag_start_y = e.x, e.y self._render_image() def _zoom_in(self): self.zoom_1to1_active=False; self.zoom_level=min(10.0,self.zoom_level*1.2); self._render_image() def _zoom_out(self): self.zoom_1to1_active=False; self.zoom_level=max(0.1,self.zoom_level/1.2); self._render_image() def _zoom_1to1(self): self.zoom_1to1_active=True; self.pan_x=0; self.pan_y=0; self._render_image() def _reset_zoom(self): self.zoom_1to1_active=False; self.zoom_level=1.0; self.pan_x=0; self.pan_y=0; self._render_image() class WatchPointSettingsDialog: """A dialog for changing application settings.""" def __init__(self, parent, settings_manager): self.settings = settings_manager self.dialog = tk.Toplevel(parent) self.dialog.title("Settings") self.dialog.transient(parent) self.dialog.grab_set() self.dialog.resizable(False, False) self.show_toolbar_var = tk.BooleanVar(value=self.settings.get("show_toolbar")) self.save_format_var = tk.StringVar(value=self.settings.get("save_format")) self.jpeg_quality_var = tk.IntVar(value=self.settings.get("jpeg_quality")) frame = tk.Frame(self.dialog, padx=15, pady=15) frame.pack(fill="both", expand=True) tk.Checkbutton(frame, text="Show Toolbar on Startup", variable=self.show_toolbar_var).pack(anchor="w") fmt_frame = tk.LabelFrame(frame, text="Default Save Format", padx=10, pady=10) fmt_frame.pack(anchor="w", fill="x", pady=(10, 5)) tk.Radiobutton(fmt_frame, text="PNG (lossless)", variable=self.save_format_var, value="png", command=self._toggle_quality).pack(anchor="w") tk.Radiobutton(fmt_frame, text="JPEG (compressed)", variable=self.save_format_var, value="jpeg", command=self._toggle_quality).pack(anchor="w") self.q_frame = tk.LabelFrame(frame, text="JPEG Quality", padx=10, pady=10) self.q_frame.pack(anchor="w", fill="x", pady=(0, 10)) self.q_scale = tk.Scale(self.q_frame, from_=1, to=100, orient="horizontal", variable=self.jpeg_quality_var) self.q_scale.pack(anchor="w", fill="x") self._toggle_quality() btn_frame = tk.Frame(frame) btn_frame.pack(fill="x", side="bottom") tk.Button(btn_frame, text="Save", command=self._save_close).pack(side="right", padx=(5,0)) tk.Button(btn_frame, text="Cancel", command=self.dialog.destroy).pack(side="right") self.dialog.wait_window() def _toggle_quality(self): state = "normal" if self.save_format_var.get() == "jpeg" else "disabled" for child in self.q_frame.winfo_children(): child.configure(state=state) def _save_close(self): self.settings.set("show_toolbar", self.show_toolbar_var.get()) self.settings.set("save_format", self.save_format_var.get()) self.settings.set("jpeg_quality", self.jpeg_quality_var.get()) self.settings.save() self.dialog.destroy() # Node Registration NODE_CLASS_MAPPINGS = {"WatchPoint": WatchPoint} NODE_DISPLAY_NAME_MAPPINGS = {"WatchPoint": "๐Ÿ‘๏ธ Watch Point"} # Signal Scout Integration class WPSignalScout: """A simple node to send text to all Watch Point windows.""" @classmethod def INPUT_TYPES(cls): return {"required": {"text": ("STRING", {"multiline": True, "default": ""})}} RETURN_TYPES = ("STRING",) FUNCTION = "scout_signal" CATEGORY = "WatchPoint" def scout_signal(self, text): try: WindowManager().update_all_text(text) except Exception as e: print(f"WP_Scout Error: Could not connect to Watch Point window: {e}") # Global Shutdown Function def cleanup_all_watchpoints(): """Function to clean up all WatchPoint nodes without using atexit""" shutdown_registry.shutdown_all() # Add the function to the module so it's available __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "cleanup_all_watchpoints"] NODE_CLASS_MAPPINGS["WPSignalScout"] = WPSignalScout NODE_DISPLAY_NAME_MAPPINGS["WPSignalScout"] = "๐Ÿ“ก WP Signal Scout" # Debug Node class WatchPointDebug: """Debug node for WatchPoint - Controls debug mode and captures dumps""" @classmethod def INPUT_TYPES(cls): return { "required": { "action": (["enable_debug", "disable_debug", "save_dump", "get_stats", "get_threads"],), } } RETURN_TYPES = ("STRING",) FUNCTION = "execute_debug_action" CATEGORY = "WatchPoint" def execute_debug_action(self, action): """Execute debug action and return result""" if action == "enable_debug": wp_logger.set_debug_mode(True) return ("โœ… Debug mode enabled",) elif action == "disable_debug": wp_logger.set_debug_mode(False) return ("โšช Debug mode disabled",) elif action == "save_dump": # Find an active WatchPoint instance watchpoint_instance = self._find_watchpoint_instance() filepath = wp_logger.save_debug_dump(watchpoint_instance) if filepath: return (f"๐Ÿ’พ Debug dump saved: {os.path.basename(filepath)}",) else: return ("โŒ Error saving debug dump (debug mode disabled?)",) elif action == "get_stats": watchpoint_instance = self._find_watchpoint_instance() if watchpoint_instance and hasattr(watchpoint_instance, 'get_health_stats'): stats = watchpoint_instance.get_health_stats() stats_text = json.dumps(stats, indent=2, default=str) return (f"๐Ÿ“Š Health Statistics:\n{stats_text}",) else: return ("โ„น๏ธ No active WatchPoint instance found",) elif action == "get_threads": watchpoint_instance = self._find_watchpoint_instance() if watchpoint_instance and hasattr(watchpoint_instance, 'debug_threads'): threads = watchpoint_instance.debug_threads() threads_text = json.dumps(threads, indent=2, default=str) return (f"๐Ÿงต Thread Information:\n{threads_text}",) else: return ("โ„น๏ธ No active WatchPoint instance found",) return ("โŒ Action not recognized",) def _find_watchpoint_instance(self): """Find an active WatchPoint instance in the global registry""" try: for node in shutdown_registry._nodes: if isinstance(node, WatchPoint): return node except Exception: pass return None # Add the debug node to mappings NODE_CLASS_MAPPINGS["WatchPointDebug"] = WatchPointDebug NODE_DISPLAY_NAME_MAPPINGS["WatchPointDebug"] = "๐Ÿ”ง Watch Point Debug"