Added scout and merged list cycler

This commit is contained in:
Alex Bautista
2026-01-21 22:55:07 -06:00
parent bbfaa82f8e
commit 9c52835712
8 changed files with 348 additions and 478 deletions
+9 -1
View File
@@ -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/
+8 -15
View File
@@ -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"]
Binary file not shown.
Binary file not shown.
+36
View File
@@ -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
};
}
}
});
+115
View File
@@ -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)"}
+29
View File
@@ -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" }
+151 -462
View File
@@ -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("<ButtonPress-1>", self._on_mouse_down)
self.canvas.bind("<B1-Motion>", self._on_mouse_drag)
self.canvas.bind("<ButtonRelease-1>", self._on_mouse_up)
self.canvas.bind("<MouseWheel>", self._on_mouse_wheel)
self.canvas.bind("<Button-3>", self._show_context_menu)
# Keyboard events
self.root.bind("<KeyPress-r>", lambda e: self._reset_zoom())
self.root.bind("<KeyPress-R>", lambda e: self._reset_zoom())
self.root.bind("<KeyPress-t>", lambda e: self._toggle_toolbar())
self.root.bind("<KeyPress-T>", lambda e: self._toggle_toolbar())
self.root.bind("<KeyPress-1>", lambda e: self._zoom_1to1())
self.root.bind("<r>", lambda e: self._reset_zoom())
self.root.bind("<t>", lambda e: self._toggle_toolbar())
self.root.bind("<p>", lambda e: self._toggle_drawer())
self.root.bind("<Escape>", 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 = {