From 034ce34e22b41fed4ed818b54b4b645b2b4d4455 Mon Sep 17 00:00:00 2001 From: Gmorks Date: Mon, 26 Jan 2026 20:59:37 -0600 Subject: [PATCH] feat(settings): add global monitor selection via settings dialog - Move monitor selection from node dropdown to global settings file - Add monitor selection UI to settings dialog with live window repositioning - Keep Tkinter window persistent to reduce threading errors - Update documentation to reflect new configuration approach --- CHANGELOG.md | 15 ++++-- README.md | 33 +++++++----- watch_point.py | 114 ++++++++++++++++++++++++--------------- watchpoint_settings.json | 3 +- 4 files changed, 106 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b6587e..2002d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed watchpoint_utils.py and watchpoint_debug_config.json. - Removed debug-specific logging and dump features. +### Added +- Global monitor configuration via `watchpoint_settings.json` using `monitor_index`. +- Monitor selection UI inside the Tkinter settings dialog (⚙), with live window repositioning. + ### Changed - Simplified WatchPoint node structure. - Integrated text input (formerly Signal Scout) directly into WatchPoint node. +- External monitor selection moved from the node dropdown to the global settings file. +- `monitor_preview` now controls whether the image updates, instead of creating/destroying the Tkinter window. - Optimized WatchPointLogger for standard logging only. -- Updated documentation to reflect cleanup. +- Updated documentation to reflect cleanup and new monitor configuration. + +### Fixed +- Reduced `main thread is not in main loop` and `Tcl_AsyncDelete` errors by keeping a single persistent Tkinter window instance. +- Avoided dead preview threads by no longer destroying the window when `monitor_preview` is disabled. ## [1.0.0] - 2026-01-24 @@ -27,5 +37,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Signal Scout node for text debugging. - Window minimization protection to prevent threading crashes. - MIT License and standardized documentation. - ---- \ No newline at end of file +--- diff --git a/README.md b/README.md index c71b58e..582ed1a 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,8 @@ The main preview node. 2. Connect an `IMAGE` input. 3. Enable/disable previews: * `floating_preview`: Enable ComfyUI's built-in preview. -* `monitor_preview`: Enable the external monitor window. -4. Select the target `monitor` from the dropdown. +* `monitor_preview`: Enable or pause the external monitor window (window stays alive, image updates only when True). +4. Configure the target monitor via the **⚙ Settings** dialog or by editing `watchpoint_settings.json` (`monitor_index`). 5. The node acts as a pass-through for the `IMAGE` output. ### Optional string input @@ -142,14 +142,19 @@ Access settings via the **⚙ Settings** button in the toolbar. ### Save Options -* Default format: PNG, JPEG, or WebP +* Default format: PNG or JPEG * JPEG quality slider (10-100) +### Monitor Options + +* Select the target monitor from the **Monitor** dropdown in the Settings dialog. +* Persist the monitor selection globally via `monitor_index` in `watchpoint_settings.json`. + ### UI Options * Show/hide toolbar by default -All settings are saved to `watchpoint_settings.json` +All settings are saved to `watchpoint_settings.json`. --- @@ -168,7 +173,8 @@ Edit `watchpoint_settings.json` to customize defaults for the external Tkinter w "window_size_mode": "fixed", "show_toolbar": true, "save_format": "png", - "jpeg_quality": 90 + "jpeg_quality": 90, + "monitor_index": 0 } ``` @@ -178,8 +184,9 @@ Edit `watchpoint_settings.json` to customize defaults for the external Tkinter w * `window_x`, `window_y`: Window position (null = auto) * `window_size_mode`: "fixed", "Half Vertical", "Half Horizontal", or "Quarter" * `show_toolbar`: Show toolbar on startup -* `save_format`: Default save format ("png", "jpeg", "webp") +* `save_format`: Default save format ("png" or "jpeg") * `jpeg_quality`: JPEG compression quality (10-100) +* `monitor_index`: Index of the monitor used for the external preview (0 = first monitor). ### Floating Preview Configuration @@ -385,9 +392,10 @@ pip install pywin32 ### Window doesn't appear -* Check if `monitor_preview` is enabled (True) -* Try different monitor selection -* Check console for error messages +* Check if `monitor_preview` is enabled (True). +* Open the **⚙ Settings** dialog and verify that a valid monitor is selected. +* Alternatively, check that `monitor_index` in `watchpoint_settings.json` points to an existing monitor. +* Check console for error messages. ### Window appears in wrong location @@ -396,9 +404,10 @@ pip install pywin32 ### Image not updating -* Ensure workflow is executing -* Check that image is connected to Watch Point input -* Try disabling/re-enabling monitor preview +* Ensure workflow is executing. +* Check that image is connected to Watch Point input. +* Verify that `monitor_preview` is enabled (True); when False, the window stays open but the image will not refresh. +* Try disabling/re-enabling monitor preview. ### Floating preview not working diff --git a/watch_point.py b/watch_point.py index 7dcd832..b990423 100644 --- a/watch_point.py +++ b/watch_point.py @@ -142,6 +142,7 @@ class SettingsManager: "window_x": None, "window_y": None, "window_size_mode": "fixed", "show_toolbar": True, "save_format": "png", "jpeg_quality": 90, + "monitor_index": 0, } self.settings = self.load() @@ -197,9 +198,11 @@ class WindowManager: wp_logger.info("WindowManager initialized", "WindowManager") - def show_image(self, display_idx, pil_img, text=None): + def show_image(self, pil_img, text=None): """Creates or updates THE SINGLE global window to display an image and optional text.""" + target_monitor_idx = self.settings_manager.get("monitor_index", 0) + # Check if any window exists, reuse it if len(self.windows) > 0: # Get the index of the existing window (there should only be 1) @@ -224,22 +227,28 @@ class WindowManager: if text and win_data.get("instance"): win_data["instance"].update_signal_text(text) - # Update title if monitor changed - if existing_idx != display_idx: - # Monitor changed, update title - wp_logger.info(f"Monitor changed from {existing_idx} to {display_idx} (only updates title)", "ShowImage") - instance = win_data.get("instance") - if instance and hasattr(instance, 'root'): + # Update monitor if changed + if existing_idx != target_monitor_idx: + # Monitor changed, move window + wp_logger.info(f"Monitor changed from {existing_idx} to {target_monitor_idx} - Moving Window", "ShowImage") + + # Move data to new key + self.windows[target_monitor_idx] = self.windows.pop(existing_idx) + win_data = self.windows[target_monitor_idx] + + if win_data.get("instance"): + win_data["instance"].display_idx = target_monitor_idx + # Move the window try: - # Update simplified title - instance.root.title("Watch Point") + self._apply_geometry(win_data["instance"].root, target_monitor_idx) except Exception as e: - wp_logger.warning(f"Could not update title: {e}", "ShowImage") + wp_logger.error(f"Error moving window: {e}", "ShowImage") return # If we reach here, NO window exists, create a new one - wp_logger.info(f"Creating new global window on Monitor {display_idx}", "ShowImage") + wp_logger.info(f"Creating new global window on Monitor {target_monitor_idx}", "ShowImage") + display_idx = target_monitor_idx lock = Lock() win_data = { @@ -586,24 +595,17 @@ class WatchPoint: """The main ComfyUI node class.""" def __init__(self): self.window_manager = window_manager - self.last_display_idx = -1 # Register for cleanup shutdown_registry.register(self) @classmethod def INPUT_TYPES(cls): - monitors = ["Monitor 0 (Default)"] - if SCREENINFO_AVAILABLE: - try: - monitors = [f"Monitor {i} ({m.width}x{m.height})" for i, m in enumerate(get_monitors())] - except Exception: pass return { "required": { "images": ("IMAGE",), "floating_preview": ("BOOLEAN", {"default": True}), "monitor_preview": ("BOOLEAN", {"default": True}), - "monitor": (monitors, {"default": monitors[0]}), }, "optional": {"opt_signal_text": ("STRING", {"forceInput": True, "multiline": True})} } @@ -613,22 +615,15 @@ class WatchPoint: OUTPUT_NODE = True CATEGORY = "WatchPoint" - def watch(self, images, floating_preview=True, monitor_preview=True, monitor="Monitor 0", opt_signal_text=None): - try: - display_idx = int(monitor.split(" ")[1]) - except (ValueError, IndexError): - display_idx = 0 - - self.last_display_idx = display_idx - + def watch(self, images, floating_preview=True, monitor_preview=True, opt_signal_text=None): if monitor_preview: - wp_logger.debug(f"Showing preview on monitor {display_idx}", "WatchPoint") image = 255.0 * images[0].cpu().numpy() pil_img = Image.fromarray(np.clip(image, 0, 255).astype(np.uint8)) - self.window_manager.show_image(display_idx, pil_img, opt_signal_text) - else: - wp_logger.debug(f"Hiding preview on monitor {display_idx}", "WatchPoint") - self.window_manager.hide_window(display_idx) + self.window_manager.show_image(pil_img, opt_signal_text) + + # If monitor_preview is False, we do NOTHING. + # The window remains open (static) if it was already open. + # We do NOT call hide_window(). ui_images = self._prepare_preview(images) if floating_preview else [] @@ -908,7 +903,7 @@ class WatchPointWindow: except Exception as e: messagebox.showerror("Save Error", f"Could not save image:\n{e}") - def _open_settings(self): WatchPointSettingsDialog(self.root, self.settings) + def _open_settings(self): WatchPointSettingsDialog(self.root, self.settings, self) def _on_mouse_wheel(self, e): self._zoom_in() if e.delta > 0 else self._zoom_out() def _on_mouse_down(self, e): self.is_dragging, self.drag_start_x, self.drag_start_y = True, e.x, e.y def _on_mouse_up(self, e): self.is_dragging = False @@ -926,8 +921,9 @@ class WatchPointWindow: class WatchPointSettingsDialog: """A dialog for changing application settings.""" - def __init__(self, parent, settings_manager): + def __init__(self, parent, settings_manager, window_instance=None): self.settings = settings_manager + self.window_instance = window_instance self.dialog = tk.Toplevel(parent) self.dialog.title("Settings") self.dialog.transient(parent) @@ -940,6 +936,26 @@ class WatchPointSettingsDialog: frame = tk.Frame(self.dialog, padx=15, pady=15) frame.pack(fill="both", expand=True) + + # Monitor Selection + if SCREENINFO_AVAILABLE: + try: + monitors = [f"Monitor {i} ({m.width}x{m.height})" for i, m in enumerate(get_monitors())] + if monitors: + m_frame = tk.LabelFrame(frame, text="Monitor", padx=10, pady=10) + m_frame.pack(anchor="w", fill="x", pady=(0, 10)) + + current_idx = self.settings.get("monitor_index", 0) + self.monitor_var = tk.StringVar() + try: + self.monitor_var.set(monitors[current_idx]) + except IndexError: + self.monitor_var.set(monitors[0]) + + tk.OptionMenu(m_frame, self.monitor_var, *monitors, command=self._on_monitor_change).pack(anchor="w") + except Exception as e: + print(f"Watch Point: Error listing monitors: {e}") + tk.Checkbutton(frame, text="Show Toolbar on Startup", variable=self.show_toolbar_var).pack(anchor="w") fmt_frame = tk.LabelFrame(frame, text="Default Save Format", padx=10, pady=10) @@ -949,21 +965,33 @@ class WatchPointSettingsDialog: self.q_frame = tk.LabelFrame(frame, text="JPEG Quality", padx=10, pady=10) self.q_frame.pack(anchor="w", fill="x", pady=(0, 10)) - self.q_scale = tk.Scale(self.q_frame, from_=1, to=100, orient="horizontal", variable=self.jpeg_quality_var) - self.q_scale.pack(anchor="w", fill="x") + + tk.Scale(self.q_frame, from_=10, to=100, orient=tk.HORIZONTAL, variable=self.jpeg_quality_var).pack(fill="x") self._toggle_quality() - btn_frame = tk.Frame(frame) - btn_frame.pack(fill="x", side="bottom") - tk.Button(btn_frame, text="Save", command=self._save_close).pack(side="right", padx=(5,0)) - tk.Button(btn_frame, text="Cancel", command=self.dialog.destroy).pack(side="right") - self.dialog.wait_window() + btn_frame = tk.Frame(self.dialog, pady=10) + btn_frame.pack(fill="x") + tk.Button(btn_frame, text="Save & Close", command=self._save_and_close, bg="#4a4a4a", fg="white").pack(side="right") + + def _on_monitor_change(self, selected_str): + try: + idx = int(selected_str.split(" ")[1]) + self.settings.set("monitor_index", idx) + self.settings.save() + + # Live update if possible + if self.window_instance and self.window_instance.current_pil_image: + self.window_instance.manager.show_image(self.window_instance.current_pil_image) + except Exception as e: + print(f"Watch Point: Error changing monitor: {e}") def _toggle_quality(self): - state = "normal" if self.save_format_var.get() == "jpeg" else "disabled" - for child in self.q_frame.winfo_children(): child.configure(state=state) + if self.save_format_var.get() == "jpeg": + self.q_frame.pack(anchor="w", fill="x", pady=(0, 10), after=self.dialog.winfo_children()[0].winfo_children()[-2]) + else: + self.q_frame.pack_forget() - def _save_close(self): + def _save_and_close(self): self.settings.set("show_toolbar", self.show_toolbar_var.get()) self.settings.set("save_format", self.save_format_var.get()) self.settings.set("jpeg_quality", self.jpeg_quality_var.get()) diff --git a/watchpoint_settings.json b/watchpoint_settings.json index 79a8e8a..ef6d655 100644 --- a/watchpoint_settings.json +++ b/watchpoint_settings.json @@ -6,5 +6,6 @@ "window_size_mode": "Half Vertical", "show_toolbar": true, "save_format": "png", - "jpeg_quality": 90 + "jpeg_quality": 90, + "monitor_index": 1 } \ No newline at end of file