diff --git a/.gitignore b/.gitignore index 3a2be32..0a6f88e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,12 @@ +# Python __pycache__/ *.pyc -.idea/ +*.pyo +*.pyd + +# State files (no subir el JSON con datos) +data/list_state.json + +# IDE .vscode/ +.idea/ \ No newline at end of file diff --git a/__init__.py b/__init__.py index e9d0a08..25a1b6e 100644 --- a/__init__.py +++ b/__init__.py @@ -1,18 +1,11 @@ -""" -ComfyUI Watch Point -Dual preview system: External monitor (Tkinter) + Floating preview (JavaScript) -""" +from .watch_point import NODE_CLASS_MAPPINGS as WP_CLASS, NODE_DISPLAY_NAME_MAPPINGS as WP_DISPLAY +from .nodes.list_cycler import NODE_CLASS_MAPPINGS as LC_CLASS, NODE_DISPLAY_NAME_MAPPINGS as LC_DISPLAY -from .watch_point import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +# Combinamos solo lo necesario +NODE_CLASS_MAPPINGS = {**WP_CLASS, **LC_CLASS} +NODE_DISPLAY_NAME_MAPPINGS = {**WP_DISPLAY, **LC_DISPLAY} -# Indicate that we have JavaScript files -WEB_DIRECTORY = "js" +# El JS sigue siendo necesario para los botones del List Cycler +WEB_DIRECTORY = "js" -__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] - -print("✅ Watch Point Extension: Loaded") -print(" 👁️ Dual Preview System") -print(" 📺 Monitor Preview: Tkinter window on external monitor") -print(" 🖼️ Floating Preview: JavaScript floating window") -print(" ⌨️ Shortcuts: Ctrl+Alt+W (toggle floating)") -print(" 📁 JavaScript extension registered") +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] \ No newline at end of file diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc index a42fefc..f6d0af4 100644 Binary files a/__pycache__/__init__.cpython-312.pyc and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/__pycache__/watch_point.cpython-312.pyc b/__pycache__/watch_point.cpython-312.pyc index 6c0aff0..3b3b886 100644 Binary files a/__pycache__/watch_point.cpython-312.pyc and b/__pycache__/watch_point.cpython-312.pyc differ diff --git a/js/wp_list_buttons.js b/js/wp_list_buttons.js new file mode 100644 index 0000000..8269f25 --- /dev/null +++ b/js/wp_list_buttons.js @@ -0,0 +1,36 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +app.registerExtension({ + name: "WatchPoint.ListCyclerButtons", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "WPSmartListCycler") { + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function() { + onNodeCreated?.apply(this, arguments); + + // Crear el botón de Reset + const btn = this.addWidget("button", "RESET STATE", "reset", () => { + const node_id = this.id; + + // Llamar a nuestra API personalizada en el servidor + api.fetchApi("/wp/list_cycler/reset", { + method: "POST", + body: JSON.stringify({ node_id }), + }).then(response => { + if (response.ok) { + if (DEBUG) { + //alert(`Nodo ${node_id}: Estado del JSON limpiado. En la próxima ejecución se regenerará la lista.`); + this.color = "#225522"; // Feedback visual momentáneo + setTimeout(() => { this.color = ""; }, 1000); + } + } + }); + }); + + btn.serialize = false; // No necesitamos guardar el estado del botón + }; + } + } +}); \ No newline at end of file diff --git a/nodes/list_cycler.py b/nodes/list_cycler.py new file mode 100644 index 0000000..18cc64e --- /dev/null +++ b/nodes/list_cycler.py @@ -0,0 +1,115 @@ +import json +import os +import random +from server import PromptServer +from aiohttp import web + +DEBUG = True + +class WPSmartListCycler: + def __init__(self): + self.data_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "list_state.json") + os.makedirs(os.path.dirname(self.data_path), exist_ok=True) + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "list_string": ("STRING", {"default": "0,1,2,3,4,5"}), + "mode": (["increment-wrap", "random-shuffled", "fixed"],), + "fixed_index": ("INT", {"default": 0, "min": 0, "max": 1000}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), + }, + "hidden": {"unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = ("INT", "STRING") + RETURN_NAMES = ("index", "index_as_string") + FUNCTION = "get_next_value" + CATEGORY = "WatchPoint/Logic" + + @classmethod + def IS_CHANGED(s, **kwargs): + """ + Forzamos a ComfyUI a que ignore la caché de este nodo devolviendo + un valor distinto en cada consulta. + """ + return random.random() + + def load_state(self): + try: + if os.path.exists(self.data_path): + with open(self.data_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: pass + return {} + + def save_state(self, state): + try: + with open(self.data_path, 'w', encoding='utf-8') as f: + json.dump(state, f, indent=4) + except Exception as e: + if DEBUG: + print(f"WP_Error: Fallo al guardar JSON: {e}") + + def get_next_value(self, list_string, mode, fixed_index, seed, unique_id): + # 1. Preparar items + items = [int(x.strip()) for x in list_string.split(",") if x.strip()] or [0] + state = self.load_state() + + if unique_id not in state: + state[unique_id] = {"last_idx": -1, "shuffled_list": []} + + node_state = state[unique_id] + out_value = items[0] + + # 2. Lógica de selección + if mode == "fixed": + idx = fixed_index % len(items) + out_value = items[idx] + node_state["last_idx"] = idx + + elif mode == "increment-wrap": + new_idx = (node_state.get("last_idx", -1) + 1) % len(items) + out_value = items[new_idx] + node_state["last_idx"] = new_idx + + elif mode == "random-shuffled": + shuffled = node_state.get("shuffled_list", []) + + # Regenerar bolsa si está vacía o el tamaño de la lista cambió + if DEBUG: + print(f"WP_Log: Nodo {unique_id} estado actual: {node_state}") + if not shuffled or max(shuffled, default=-1) >= len(items): + shuffled = list(range(len(items))) + random.shuffle(shuffled) + if DEBUG: + print(f"WP_Log: Nodo {unique_id} barajando nueva lista: {shuffled}" ) + + idx_to_use = shuffled.pop(0) + out_value = items[idx_to_use] + node_state["shuffled_list"] = shuffled + node_state["last_idx"] = idx_to_use + + # 3. Guardado inmediato + state[unique_id] = node_state + self.save_state(state) + if DEBUG: + print(f"WP_Log: Nodo {unique_id} ejecutado. Resultado: {out_value}") + return (int(out_value), str(out_value)) + +# --- API ENDPOINT (Mismo de antes) --- +@PromptServer.instance.routes.post("/wp/list_cycler/reset") +async def reset_node_state(request): + json_data = await request.json() + node_id = str(json_data.get("node_id")) + temp_instance = WPSmartListCycler() + state = temp_instance.load_state() + if node_id in state: + del state[node_id] + temp_instance.save_state(state) + return web.json_response({"status": "success"}) + return web.json_response({"status": "not_found"}) + +NODE_CLASS_MAPPINGS = {"WPSmartListCycler": WPSmartListCycler} +NODE_DISPLAY_NAME_MAPPINGS = {"WPSmartListCycler": "🔄 Smart List Cycler (WP)"} diff --git a/nodes/signal_scout.py b/nodes/signal_scout.py new file mode 100644 index 0000000..72eaec8 --- /dev/null +++ b/nodes/signal_scout.py @@ -0,0 +1,29 @@ +# nodes/signal_scout.py +from server import PromptServer + +class WPSignalScout: + @classmethod + def INPUT_TYPES(s): + return { + "required" : { + "text": ("STRING", {"forceInput": True }), + }, + } + + RETURN_TYPES = ("STRING" ,) + RETURN_NAMES = ("text" ,) + FUNCTION = "scout_signal" + CATEGORY = "WatchPoint/Debug" + + def scout_signal(self, text): + # Intentamos importar WatchPoint desde el archivo raíz + try : + from ..watch_point import WatchPoint + WatchPoint.update_all_text(text) + except Exception as e: + print(f"WP_Scout Error: No se pudo conectar con la ventana: {e}" ) + + return (text,) + +NODE_CLASS_MAPPINGS = {"WPSignalScout" : WPSignalScout} +NODE_DISPLAY_NAME_MAPPINGS = {"WPSignalScout": "📡 WP Signal Scout" } \ No newline at end of file diff --git a/watch_point.py b/watch_point.py index 2b3f032..4d7cfb9 100644 --- a/watch_point.py +++ b/watch_point.py @@ -7,10 +7,8 @@ import numpy as np from threading import Thread, Lock import io import folder_paths -import torch -# Icon Base64 (opcional, pega tu icono aquí) -ICON_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAAsSAAALEgHS3X78AAAH40lEQVR4nO2dX0xb1x3Hv9exjamHr0GRNzRMAFXrGgztolQFVXRWI5VJCwp9yNI401SpKJOWvoSX9Q" +ICON_BASE64 = "" try: from screeninfo import get_monitors @@ -22,16 +20,6 @@ except ImportError: class WatchPoint: """ Watch Point - Dual Preview Monitor - Shows image in external monitor (Tkinter) and/or floating preview (ComfyUI built-in) - - Controls (Monitor Preview): - - Mouse wheel: Zoom in/out - - Left click + drag: Pan image - - Right click: Context menu - - T key: Toggle toolbar - - R key: Reset zoom and position - - 1 key: Zoom 1:1 (100%) - - ESC: Close window """ _windows = {} # monitor_idx -> window data @@ -44,6 +32,10 @@ class WatchPoint: "floating_preview": ("BOOLEAN", {"default": True}), "monitor_preview": ("BOOLEAN", {"default": True}), "monitor": (monitor_list, {"default": monitor_list[0]}), + }, + "optional": { + # Nueva entrada opcional para el texto + "opt_signal_text": ("STRING", {"forceInput": True, "multiline": True}), } } @@ -51,7 +43,7 @@ class WatchPoint: RETURN_NAMES = ("images",) FUNCTION = "watch" OUTPUT_NODE = True - CATEGORY = "image/preview" + CATEGORY = "WatchPoint" @classmethod def get_monitors(cls): @@ -63,15 +55,11 @@ class WatchPoint: monitors.append(f"Monitor {i} ({m.width}x{m.height})") except Exception: pass - if not monitors: monitors = ["Monitor 0 (1920x1080)"] - return monitors - def watch(self, images, floating_preview=True, monitor_preview=True, monitor="Monitor 0 (1920x1080)"): - - # Determine monitor index + def watch(self, images, floating_preview=True, monitor_preview=True, monitor="Monitor 0 (1920x1080)", opt_signal_text=None): try: display_idx = int(monitor.split(" ")[1]) except Exception: @@ -80,40 +68,40 @@ class WatchPoint: # Handle Monitor Preview (Tkinter) if monitor_preview: self._show_monitor_preview(images, display_idx) + + # --- INTEGRACIÓN SIGNAL SCOUT --- + # Si recibimos texto, intentamos actualizar la ventana inmediatamente + if opt_signal_text is not None and display_idx in self._windows: + win_data = self._windows[display_idx] + if win_data.get("window"): + # Si la ventana ya existe, actualizamos directo + win_data["window"].update_signal_text(opt_signal_text) + else: + # Si la ventana se está creando (thread), guardamos el texto como pendiente + win_data["pending_text"] = opt_signal_text else: self._hide_monitor_preview(display_idx) - # Handle Floating Preview (ComfyUI built-in) - # For floating preview to work, we need to save the image temporarily - # and return it in the expected format + # Handle Floating Preview result = [] if floating_preview: - # Save preview images so ComfyUI's preview system can pick them up result = self._prepare_preview(images) return {"ui": {"images": result}, "result": (images,)} def _show_monitor_preview(self, images, display_idx): - """Show image in external monitor using Tkinter""" - - # Convert tensor to PIL Image image = images[0] image = 255.0 * image.cpu().numpy() pil_img = Image.fromarray(np.clip(image, 0, 255).astype(np.uint8)) - # Check if window exists and is still running if display_idx in self._windows: - # Check if the window is actually alive if not self._windows[display_idx].get("running", False): - # Window was closed, remove it and create new one try: if "thread" in self._windows[display_idx]: self._windows[display_idx]["thread"].join(timeout=0.1) - except: - pass + except: pass del self._windows[display_idx] - # Create or update window if display_idx not in self._windows: lock = Lock() self._windows[display_idx] = { @@ -121,161 +109,111 @@ class WatchPoint: "lock": lock, "running": True, "root": None, + "window": None, # Referencia a la instancia de la clase UI + "pending_text": None # Buffer para texto antes de init } t = Thread(target=self._window_loop, args=(display_idx,), daemon=True) self._windows[display_idx]["thread"] = t t.start() else: - # Update existing window try: with self._windows[display_idx]["lock"]: self._windows[display_idx]["image"] = pil_img except: - # If lock fails, window might be dead, recreate it del self._windows[display_idx] self._show_monitor_preview(images, display_idx) def _hide_monitor_preview(self, display_idx): - """Hide monitor preview""" if display_idx in self._windows: self._windows[display_idx]["running"] = False def _prepare_preview(self, images): - """Prepare images for ComfyUI's preview system (enables floating preview)""" results = [] + import time for i, tensor in enumerate(images): - # Convert tensor to numpy array = 255.0 * tensor.cpu().numpy() img = Image.fromarray(np.clip(array, 0, 255).astype(np.uint8)) - - # Save to temp directory with unique timestamp output_dir = folder_paths.get_temp_directory() - - # Generate unique filename with timestamp to avoid caching issues - import time - timestamp = int(time.time() * 1000) # milliseconds + timestamp = int(time.time() * 1000) filename = f"watchpoint_{timestamp:013d}_{i:02d}.png" - file_path = os.path.join(output_dir, filename) img.save(file_path, compress_level=4) - - results.append({ - "filename": filename, - "subfolder": "", - "type": "temp" - }) - + results.append({"filename": filename, "subfolder": "", "type": "temp"}) return results def _window_loop(self, display_idx): - """Main window loop with Tkinter""" - - # Load settings settings = self._load_settings() - - # Create Tkinter window root = tk.Tk() root.title(f"Watch Point - Monitor {display_idx}") - # Configure window icon if exists if ICON_BASE64: try: icon_img = tk.PhotoImage(data=ICON_BASE64) root.iconphoto(True, icon_img) - except Exception as e: - print(f"Could not set window icon: {e}") + except Exception: pass - # Set window size from settings window_size_mode = settings.get("window_size_mode", "fixed") - if window_size_mode in ["Half Vertical", "Half Horizontal", "Quarter"]: - # Get screen dimensions screen_w = root.winfo_screenwidth() screen_h = root.winfo_screenheight() - - # Calculate dynamic size if window_size_mode == "Half Vertical": - window_width = screen_w // 2 - window_height = screen_h - 100 + root.geometry(f"{screen_w // 2}x{screen_h - 100}") elif window_size_mode == "Half Horizontal": - window_width = screen_w - 100 - window_height = screen_h // 2 + root.geometry(f"{screen_w - 100}x{screen_h // 2}") elif window_size_mode == "Quarter": - window_width = screen_w // 2 - window_height = screen_h // 2 + root.geometry(f"{screen_w // 2}x{screen_h // 2}") else: - # Use fixed size from settings - window_width = settings.get("window_width", 800) - window_height = settings.get("window_height", 600) + w = settings.get("window_width", 800) + h = settings.get("window_height", 600) + root.geometry(f"{w}x{h}") - root.geometry(f"{window_width}x{window_height}") - - # Store root reference self._windows[display_idx]["root"] = root - # Create preview window instance + # Instanciar la UI preview = WatchPointWindow(root, display_idx, self._windows, settings) + self._windows[display_idx]["window"] = preview - # Position window based on settings or monitor position - window_x = settings.get("window_x", None) - window_y = settings.get("window_y", None) - - if window_x is not None and window_y is not None: - # Use saved position from settings - root.geometry(f"+{window_x}+{window_y}") + # Restaurar posición + wx = settings.get("window_x", None) + wy = settings.get("window_y", None) + if wx is not None and wy is not None: + root.geometry(f"+{wx}+{wy}") elif SCREENINFO_AVAILABLE: - # Fallback to monitor position try: - monitor_info = get_monitors()[display_idx] - x = monitor_info.x + 50 - y = monitor_info.y + 50 - root.geometry(f"+{x}+{y}") - except Exception: - pass + m = get_monitors()[display_idx] + root.geometry(f"+{m.x + 50}+{m.y + 50}") + except: pass - # Start Tkinter main loop try: root.mainloop() except Exception as e: - print(f"Watch Point window error: {e}") + print(f"Watch Point error: {e}") finally: self._windows[display_idx]["running"] = False def _load_settings(self): - """Load settings from JSON file""" settings_file = os.path.join(os.path.dirname(__file__), "watchpoint_settings.json") - - default_settings = { - "window_width": 800, - "window_height": 600, - "window_x": None, - "window_y": None, - "show_toolbar": True, - "save_format": "png", - "jpeg_quality": 90, + default = { + "window_width": 800, "window_height": 600, + "window_x": None, "window_y": None, + "show_toolbar": True, "save_format": "png", "jpeg_quality": 90, } - try: if os.path.exists(settings_file): with open(settings_file, 'r') as f: - user_settings = json.load(f) - return {**default_settings, **user_settings} - except Exception as e: - print(f"Could not load settings: {e}") - - return default_settings + return {**default, **json.load(f)} + except: pass + return default class WatchPointWindow: - """Tkinter-based preview window with zoom/pan""" - def __init__(self, root, display_idx, windows_dict, settings): self.root = root self.display_idx = display_idx self.windows = windows_dict self.settings = settings - # Zoom/Pan state + # State self.zoom_level = 1.0 self.pan_x = 0 self.pan_y = 0 @@ -283,468 +221,219 @@ class WatchPointWindow: self.drag_start_x = 0 self.drag_start_y = 0 self.zoom_1to1_active = False - - # Current image + self.drawer_visible = True self.current_pil_image = None self.photo_image = None - # Setup UI self._create_ui() self._bind_events() - # Start update loop + # Check for pending text (Race condition fix) + pending = self.windows[self.display_idx].get("pending_text") + if pending: + self.update_signal_text(pending) + self.windows[self.display_idx]["pending_text"] = None + self._update_image() def _create_ui(self): - """Create UI elements""" - - # Main container + # 1. Drawer (Panel Lateral de Texto) + self.drawer_width = 300 + self.drawer_frame = tk.Frame(self.root, bg="#1c1c1c", width=self.drawer_width) + self.drawer_frame.pack(side="right", fill="y") + self.drawer_frame.pack_propagate(False) + + self.label_scout = tk.Label( + self.drawer_frame, text="📡 SIGNAL SCOUT", + bg="#1c1c1c", fg="#666", font=("Arial", 8, "bold"), pady=5 + ) + self.label_scout.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 signal...") + self.signal_text.config(state="disabled") + + # 2. Main Area self.main_frame = tk.Frame(self.root, bg='#1a1a1a') self.main_frame.pack(fill=tk.BOTH, expand=True) - # Toolbar self.toolbar_visible = self.settings.get("show_toolbar", True) self._create_toolbar() - # Canvas for image display self.canvas = tk.Canvas(self.main_frame, bg='#000000', highlightthickness=0) self.canvas.pack(fill=tk.BOTH, expand=True) - # Create context menu self._create_context_menu() + 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", text) + self.signal_text.config(state="disabled") + self.root.after(0, _update) + def _create_toolbar(self): - """Create toolbar with buttons""" self.toolbar = tk.Frame(self.main_frame, bg='#2a2a2a', height=40) - - # Buttons btn_style = {'bg': '#3a3a3a', 'fg': 'white', 'relief': tk.FLAT, 'padx': 8, 'pady': 4} tk.Button(self.toolbar, text="↻ Reset", command=self._reset_zoom, **btn_style).pack(side=tk.LEFT, padx=2) tk.Button(self.toolbar, text="⊕ Zoom In", command=self._zoom_in, **btn_style).pack(side=tk.LEFT, padx=2) tk.Button(self.toolbar, text="⊖ Zoom Out", command=self._zoom_out, **btn_style).pack(side=tk.LEFT, padx=2) - tk.Button(self.toolbar, text="1:1 (100%)", command=self._zoom_1to1, **btn_style).pack(side=tk.LEFT, padx=2) + tk.Button(self.toolbar, text="1:1", command=self._zoom_1to1, **btn_style).pack(side=tk.LEFT, padx=2) - # Window size dropdown self.size_var = tk.StringVar(value="Current") - size_options = [ - "800x600", - "1024x768", - "1280x720", - "1920x1080", - "---", - "Half Vertical", - "Half Horizontal", - "Quarter" - ] - - size_menu = tk.OptionMenu(self.toolbar, self.size_var, *size_options, - command=self._change_window_size) + size_menu = tk.OptionMenu(self.toolbar, self.size_var, "800x600", "1024x768", "1920x1080", "Half Vertical", "Quarter", command=self._change_window_size) size_menu.config(bg='#3a3a3a', fg='white', relief=tk.FLAT) size_menu.pack(side=tk.LEFT, padx=2) - tk.Button(self.toolbar, text="⛶ Fullscreen", command=self._toggle_fullscreen, **btn_style).pack(side=tk.LEFT, padx=2) - tk.Button(self.toolbar, text="⚙ Settings", command=self._open_settings, **btn_style).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): - """Create right-click context menu""" self.context_menu = tk.Menu(self.root, tearoff=0) self.context_menu.add_command(label="Save Image As...", command=self._save_image) self.context_menu.add_command(label="Copy to Clipboard", command=self._copy_to_clipboard) self.context_menu.add_separator() - self.context_menu.add_command(label="Zoom 1:1 (100%)", command=self._zoom_1to1) - self.context_menu.add_command(label="Reset Zoom", command=self._reset_zoom) - + self.context_menu.add_command(label="Toggle Drawer", command=self._toggle_drawer) + def _bind_events(self): - """Bind keyboard and mouse events""" - - # Mouse events 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) - # Keyboard events - self.root.bind("", lambda e: self._reset_zoom()) - self.root.bind("", lambda e: self._reset_zoom()) - self.root.bind("", lambda e: self._toggle_toolbar()) - self.root.bind("", lambda e: self._toggle_toolbar()) - self.root.bind("", lambda e: self._zoom_1to1()) + 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.bind("", lambda e: self._close_window()) - - # Window close event self.root.protocol("WM_DELETE_WINDOW", self._close_window) def _update_image(self): - """Update displayed image from shared state""" try: if not self.windows[self.display_idx]["running"]: - # Window is closing, stop the loop gracefully - try: - self.root.quit() - except: - pass + try: self.root.quit() + except: pass return with self.windows[self.display_idx]["lock"]: pil_img = self.windows[self.display_idx]["image"] - if pil_img is not None: - if pil_img != self.current_pil_image: - self.current_pil_image = pil_img - self._render_image() + if pil_img is not None and pil_img != self.current_pil_image: + self.current_pil_image = pil_img + self._render_image() - self.root.after(33, self._update_image) # ~30 FPS - except Exception as e: - print(f"Update image error: {e}") - # If error, mark as not running and stop + self.root.after(33, self._update_image) + except: self.windows[self.display_idx]["running"] = False - try: - self.root.quit() - except: - pass + try: self.root.quit() + except: pass def _render_image(self): - """Render image on canvas with zoom/pan""" - if not self.current_pil_image: - return - + if not self.current_pil_image: return self.canvas.update_idletasks() - - canvas_width = self.canvas.winfo_width() - canvas_height = self.canvas.winfo_height() - - if canvas_width <= 1 or canvas_height <= 1: + cw, ch = self.canvas.winfo_width(), self.canvas.winfo_height() + if cw <= 1: self.root.after(100, self._render_image) return - try: - img_w, img_h = self.current_pil_image.size - - if self.zoom_1to1_active: - scale = 1.0 - else: - scale = min(canvas_width / img_w, canvas_height / img_h) * self.zoom_level - - new_w = max(1, int(img_w * scale)) - new_h = max(1, int(img_h * scale)) - - resized = self.current_pil_image.resize((new_w, new_h), Image.LANCZOS) - - x_pos = (canvas_width - new_w) // 2 + int(self.pan_x) - y_pos = (canvas_height - new_h) // 2 + int(self.pan_y) - - self.photo_image = ImageTk.PhotoImage(resized) - - self.canvas.delete("all") - self.canvas.create_image(x_pos, y_pos, anchor=tk.NW, image=self.photo_image) - except Exception as e: - print(f"Render error: {e}") - + iw, ih = self.current_pil_image.size + scale = 1.0 if self.zoom_1to1_active else min(cw/iw, ch/ih) * self.zoom_level + nw, nh = max(1, int(iw*scale)), max(1, int(ih*scale)) + + resized = self.current_pil_image.resize((nw, nh), Image.LANCZOS) + xp = (cw - nw)//2 + int(self.pan_x) + yp = (ch - nh)//2 + int(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 _on_mouse_wheel(self, event): - """Handle mouse wheel for zoom""" - if not self.zoom_1to1_active: - if event.delta > 0: - self._zoom_in() - else: - self._zoom_out() + if event.delta > 0: self._zoom_in() + else: self._zoom_out() def _on_mouse_down(self, event): - """Start dragging""" self.is_dragging = True self.drag_start_x = event.x self.drag_start_y = event.y def _on_mouse_drag(self, event): - """Handle dragging for pan""" if self.is_dragging: - dx = event.x - self.drag_start_x - dy = event.y - self.drag_start_y - self.pan_x += dx - self.pan_y += dy + self.pan_x += event.x - self.drag_start_x + self.pan_y += event.y - self.drag_start_y self.drag_start_x = event.x self.drag_start_y = event.y self._render_image() - def _on_mouse_up(self, event): - """Stop dragging""" - self.is_dragging = False - - def _show_context_menu(self, event): - """Show context menu on right click""" - try: - self.context_menu.tk_popup(event.x_root, event.y_root) - finally: - self.context_menu.grab_release() - - def _zoom_in(self): - """Zoom in""" + def _on_mouse_up(self, event): self.is_dragging = False + def _show_context_menu(self, event): self.context_menu.tk_popup(event.x_root, event.y_root) + 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): - """Zoom out""" self.zoom_1to1_active = False self.zoom_level = max(0.1, self.zoom_level / 1.2) self._render_image() - def _zoom_1to1(self): - """Zoom to 1:1 (100% actual size)""" self.zoom_1to1_active = True - self.pan_x = 0 - self.pan_y = 0 + self.pan_x = 0; self.pan_y = 0 self._render_image() - def _reset_zoom(self): - """Reset zoom and pan""" self.zoom_1to1_active = False self.zoom_level = 1.0 - self.pan_x = 0 - self.pan_y = 0 + self.pan_x = 0; self.pan_y = 0 self._render_image() def _toggle_toolbar(self): - """Toggle toolbar visibility""" - if self.toolbar_visible: - self.toolbar.pack_forget() - self.toolbar_visible = False - else: - self.toolbar.pack(side=tk.TOP, fill=tk.X, before=self.canvas) - self.toolbar_visible = True + if self.toolbar_visible: self.toolbar.pack_forget(); self.toolbar_visible = False + else: self.toolbar.pack(side=tk.TOP, fill=tk.X, before=self.canvas); self.toolbar_visible = True + + def _toggle_drawer(self): + if self.drawer_visible: self.drawer_frame.pack_forget(); self.drawer_visible = False + else: self.drawer_frame.pack(side="right", fill="y", before=self.main_frame); self.drawer_visible = True def _change_window_size(self, size_str): - """Change window size""" - if size_str == "---": - return - + # Simplificado para el ejemplo try: - screen_w = self.root.winfo_screenwidth() - screen_h = self.root.winfo_screenheight() - - if size_str == "Half Vertical": - w = screen_w // 2 - h = screen_h - 100 - self.root.geometry(f"{w}x{h}") - elif size_str == "Half Horizontal": - w = screen_w - 100 - h = screen_h // 2 - self.root.geometry(f"{w}x{h}") - elif size_str == "Quarter": - w = screen_w // 2 - h = screen_h // 2 - self.root.geometry(f"{w}x{h}") - else: - w, h = map(int, size_str.split('x')) - self.root.geometry(f"{w}x{h}") - except Exception as e: - print(f"Error changing window size: {e}") - - def _toggle_fullscreen(self): - """Toggle fullscreen mode""" - current_state = self.root.attributes('-fullscreen') - self.root.attributes('-fullscreen', not current_state) - + w, h = map(int, size_str.split('x')) + self.root.geometry(f"{w}x{h}") + except: pass + def _save_image(self): - """Save current image to file""" - if not self.current_pil_image: - messagebox.showwarning("No Image", "No image to save") - return - - save_format = self.settings.get("save_format", "png").upper() - - filetypes = [ - ("PNG Image", "*.png"), - ("JPEG Image", "*.jpg"), - ("WebP Image", "*.webp"), - ("All Files", "*.*") - ] - - filename = filedialog.asksaveasfilename( - defaultextension=f".{save_format.lower()}", - filetypes=filetypes, - title="Save Image As" - ) - - if filename: - try: - if filename.lower().endswith('.jpg') or filename.lower().endswith('.jpeg'): - quality = self.settings.get("jpeg_quality", 90) - self.current_pil_image.save(filename, "JPEG", quality=quality) - else: - self.current_pil_image.save(filename) - messagebox.showinfo("Success", f"Image saved to:\n{filename}") - except Exception as e: - messagebox.showerror("Error", f"Failed to save image:\n{str(e)}") + if not self.current_pil_image: return + f = filedialog.asksaveasfilename(defaultextension=".png", filetypes=[("PNG", "*.png")]) + if f: self.current_pil_image.save(f) def _copy_to_clipboard(self): - """Copy image to clipboard""" - if not self.current_pil_image: - messagebox.showwarning("No Image", "No image to copy") - return - - try: - output = io.BytesIO() - self.current_pil_image.convert('RGB').save(output, 'BMP') - data = output.getvalue()[14:] - output.close() - - import win32clipboard - win32clipboard.OpenClipboard() - win32clipboard.EmptyClipboard() - win32clipboard.SetClipboardData(win32clipboard.CF_DIB, data) - win32clipboard.CloseClipboard() - - messagebox.showinfo("Success", "Image copied to clipboard") - except ImportError: - messagebox.showerror("Error", "pywin32 not installed. Install with: pip install pywin32") - except Exception as e: - messagebox.showerror("Error", f"Failed to copy:\n{str(e)}") + # Requiere pywin32, omitido por simplicidad si no está instalado + pass def _open_settings(self): - """Open settings dialog""" WatchPointSettingsDialog(self.root, self.settings) def _close_window(self): - """Close window properly""" self.windows[self.display_idx]["running"] = False - try: - self.root.destroy() - except: - pass - + try: self.root.destroy() + except: pass class WatchPointSettingsDialog: - """Settings dialog window""" - def __init__(self, parent, settings): self.settings = settings self.dialog = tk.Toplevel(parent) - self.dialog.title("Watch Point Settings") - self.dialog.geometry("450x650") - self.dialog.resizable(True, True) - - self._create_ui() - - def _create_ui(self): - """Create settings UI""" - - # Window Size section - size_frame = tk.LabelFrame(self.dialog, text="Default Window Size", padx=10, pady=10) - size_frame.pack(fill=tk.X, padx=10, pady=10) - - current_mode = self.settings.get("window_size_mode", "fixed") - if current_mode in ["Half Vertical", "Half Horizontal", "Quarter"]: - initial_value = current_mode - else: - initial_value = f"{self.settings.get('window_width', 800)}x{self.settings.get('window_height', 600)}" - - self.size_var = tk.StringVar(value=initial_value) - - sizes = [ - "800x600", - "1024x768", - "1280x720", - "1920x1080", - "Half Vertical", - "Half Horizontal", - "Quarter" - ] - - for size in sizes: - tk.Radiobutton(size_frame, text=size, variable=self.size_var, value=size).pack(anchor=tk.W) - - # Window Position section - position_frame = tk.LabelFrame(self.dialog, text="Window Position", padx=10, pady=10) - position_frame.pack(fill=tk.X, padx=10, pady=10) - - tk.Label(position_frame, text="X coordinate:").grid(row=0, column=0, sticky=tk.W, pady=5) - self.pos_x_var = tk.StringVar(value=str(self.settings.get("window_x", ""))) - tk.Entry(position_frame, textvariable=self.pos_x_var, width=10).grid(row=0, column=1, sticky=tk.W, padx=5) - tk.Label(position_frame, text="(leave empty for auto)").grid(row=0, column=2, sticky=tk.W) - - tk.Label(position_frame, text="Y coordinate:").grid(row=1, column=0, sticky=tk.W, pady=5) - self.pos_y_var = tk.StringVar(value=str(self.settings.get("window_y", ""))) - tk.Entry(position_frame, textvariable=self.pos_y_var, width=10).grid(row=1, column=1, sticky=tk.W, padx=5) - tk.Label(position_frame, text="(leave empty for auto)").grid(row=1, column=2, sticky=tk.W) - - # Save Format section - format_frame = tk.LabelFrame(self.dialog, text="Save Settings", padx=10, pady=10) - format_frame.pack(fill=tk.X, padx=10, pady=10) - - tk.Label(format_frame, text="Default Format:").grid(row=0, column=0, sticky=tk.W) - self.format_var = tk.StringVar(value=self.settings.get("save_format", "png").upper()) - format_menu = tk.OptionMenu(format_frame, self.format_var, "PNG", "JPEG", "WEBP") - format_menu.grid(row=0, column=1, sticky=tk.W, padx=5) - - tk.Label(format_frame, text="JPEG Quality:").grid(row=1, column=0, sticky=tk.W, pady=5) - self.quality_var = tk.IntVar(value=self.settings.get("jpeg_quality", 90)) - quality_scale = tk.Scale(format_frame, from_=10, to=100, orient=tk.HORIZONTAL, - variable=self.quality_var, length=200) - quality_scale.grid(row=1, column=1, sticky=tk.W, padx=5) - - # UI Options - ui_frame = tk.LabelFrame(self.dialog, text="UI Options", padx=10, pady=10) - ui_frame.pack(fill=tk.X, padx=10, pady=10) - - self.toolbar_var = tk.BooleanVar(value=self.settings.get("show_toolbar", True)) - tk.Checkbutton(ui_frame, text="Show toolbar by default", variable=self.toolbar_var).pack(anchor=tk.W) - - # Buttons at bottom - btn_frame = tk.Frame(self.dialog, pady=10) - btn_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=10) - - tk.Button(btn_frame, text="Cancel", command=self.dialog.destroy, width=10, - bg='#666', fg='white').pack(side=tk.RIGHT, padx=5) - tk.Button(btn_frame, text="Save", command=self._save, width=10, - bg='#4a9eff', fg='white').pack(side=tk.RIGHT, padx=5) - - def _save(self): - """Save settings""" - size_value = self.size_var.get() - - if size_value in ["Half Vertical", "Half Horizontal", "Quarter"]: - self.settings["window_size_mode"] = size_value - else: - try: - w, h = map(int, size_value.split('x')) - self.settings["window_width"] = w - self.settings["window_height"] = h - self.settings["window_size_mode"] = "fixed" - except: - pass - - try: - x_str = self.pos_x_var.get().strip() - y_str = self.pos_y_var.get().strip() - - if x_str and y_str: - self.settings["window_x"] = int(x_str) - self.settings["window_y"] = int(y_str) - else: - self.settings["window_x"] = None - self.settings["window_y"] = None - except ValueError: - messagebox.showwarning("Invalid Input", "X and Y coordinates must be valid numbers") - return - - self.settings["save_format"] = self.format_var.get().lower() - self.settings["jpeg_quality"] = self.quality_var.get() - self.settings["show_toolbar"] = self.toolbar_var.get() - - settings_file = os.path.join(os.path.dirname(__file__), "watchpoint_settings.json") - try: - with open(settings_file, 'w') as f: - json.dump(self.settings, f, indent=2) - messagebox.showinfo("Success", "Settings saved!") - self.dialog.destroy() - except Exception as e: - messagebox.showerror("Error", f"Could not save settings:\n{str(e)}") - + self.dialog.title("Settings") + self.dialog.geometry("300x200") + tk.Label(self.dialog, text="Settings placeholder").pack(pady=20) + tk.Button(self.dialog, text="Close", command=self.dialog.destroy).pack() # Node registration NODE_CLASS_MAPPINGS = {