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
This commit is contained in:
Gmorks
2026-01-26 20:59:37 -06:00
parent 8643cf45e7
commit 034ce34e22
4 changed files with 106 additions and 59 deletions
+12 -3
View File
@@ -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.
---
---
+21 -12
View File
@@ -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
+71 -43
View File
@@ -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())
+2 -1
View File
@@ -6,5 +6,6 @@
"window_size_mode": "Half Vertical",
"show_toolbar": true,
"save_format": "png",
"jpeg_quality": 90
"jpeg_quality": 90,
"monitor_index": 1
}