From 9c528357126178f57245df3b0b80ac220ebb0fa1 Mon Sep 17 00:00:00 2001 From: Alex Bautista Date: Wed, 21 Jan 2026 22:55:07 -0600 Subject: [PATCH] Added scout and merged list cycler --- .gitignore | 10 +- __init__.py | 23 +- __pycache__/__init__.cpython-312.pyc | Bin 894 -> 455 bytes __pycache__/watch_point.cpython-312.pyc | Bin 39930 -> 28148 bytes js/wp_list_buttons.js | 36 ++ nodes/list_cycler.py | 115 +++++ nodes/signal_scout.py | 29 ++ watch_point.py | 613 ++++++------------------ 8 files changed, 348 insertions(+), 478 deletions(-) create mode 100644 js/wp_list_buttons.js create mode 100644 nodes/list_cycler.py create mode 100644 nodes/signal_scout.py 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 a42fefc0cd65ffd7cf3245b1e339b24f19c20f45..f6d0af43346fbe8cd9f7a3f7e5a5eef930ec4a17 100644 GIT binary patch delta 320 zcmeyzcAUBXG%qg~0}wn|S(wSr#K75ca^>Q+cOX8C&lXFswZgKcHLsZ}50_z-Q-2n5>Qmp_0 literal 894 zcmZ8fO=}ZD7@pnDN8L7ph#tH+dTG|66%Qg?5o3}{BuzuM5*q@;l9{HfyE|cawlSVs z(3_xA(2F2=Qv3rV{sS)_;=$aECr?It5Ii|M`6`*i@I3F=yzevbmsCmz;+#Hz&v~u_ z@G~Nlqm9AgV*y?P4C(+YSalUot*f3^*A#%YdaMO-?5$FdtAHeM{DU&eQh?-%Ii8&3 z3v>L~9M@;OyJ##uB6^N`+ugfGShIO^2Ue)#b3Gs0E^ITh>yQo%x&bGi2@4N7VZLy9 z)OR>#FunOe1QTY!3|w=m%^klr%}U>~cWtZ5oHjS~U(pbT+CN#|$QMwql(j6ho~=}h z<=a;Oj5OwpR;85PLgnmwVdhis2ZqKX;aE&u{Zy@R3+0Q|LT+=Tx)q)iw!!NsM|+oT z`hi1z6PBor2@cPSCl&ov*dK)>Llji6I>t9Hx4o3cuv@Hd`vr# z`jE_N5nh|*zx*uUnCdYtGSdTYPsAD(46lrm2T$M0Tx*9i-VAwQ!W?IAChPJ{4W>D5 zwOleyHp1n}n+N;wNcE^UDF9@o<%qvy1ec9uKZy{gO@#W3o#?AkJNoLGwk#paMM1Xf zilBI#3HN(&D6HV$)itv*UX(^N3^?_WPcaE5#*1=$RZhbgA9#FI@L14qbAC z9T#PyEeNXO8fuE7{82R}_E!f=Q5hz{iBo&2fnM25{s8f>38M!te2>6#52Pi?^*~;N Vn>~<~!0drl30A&=%WO%M{SS|!1`_}P diff --git a/__pycache__/watch_point.cpython-312.pyc b/__pycache__/watch_point.cpython-312.pyc index 6c0aff0f72a39948b35dce8a2ac4824e00422772..3b3b886219c2db7d30b6d38e462fd2d328835e76 100644 GIT binary patch delta 13042 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 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 = {