From 503b3f7ff83cfee09e3e489729f62480a982bb75 Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 22 Apr 2026 13:08:43 -0700 Subject: [PATCH] Fix hierarchical autocomplete: select directory segments, not files; stop when direction subdir is found --- compass_image_loader.py | 76 +++++++--- js/compass_image_loader.js | 274 ++++++++++++++++++------------------- 2 files changed, 190 insertions(+), 160 deletions(-) diff --git a/compass_image_loader.py b/compass_image_loader.py index 54788ac..164a556 100644 --- a/compass_image_loader.py +++ b/compass_image_loader.py @@ -16,22 +16,57 @@ def _discover_directories(): return [] candidates = set() - for root, subdirs, _ in os.walk(base_dir, followlinks=True): + + for root, dirs, files in os.walk(base_dir, followlinks=True): rel = os.path.relpath(root, base_dir) - subdirs_lower = {s.lower() for s in subdirs} - if VALID_DIRECTIONS & subdirs_lower or VALID_MODALITIES & subdirs_lower: - if rel == ".": - continue + if rel == ".": + continue + + dirs_lower = {d.lower() for d in dirs} + + if VALID_DIRECTIONS & dirs_lower: + candidates.add(rel) + elif VALID_MODALITIES & dirs_lower: candidates.add(rel) return sorted(candidates) -def _resolve_target_dir(base_dir, directory, direction): - if directory and (not isinstance(directory, str) or directory.strip()): - path = os.path.join(base_dir, directory) +def _discover_children(parent_rel): + base_dir = folder_paths.get_input_directory() + if parent_rel: + parent_abs = os.path.join(base_dir, parent_rel) else: - path = base_dir + parent_abs = base_dir + + if not os.path.isdir(parent_abs): + return [] + + children = set() + try: + for name in os.listdir(parent_abs): + full = os.path.join(parent_abs, name) + if os.path.isdir(full): + rel = os.path.relpath(full, base_dir) + children.add(rel) + except OSError: + pass + + my_children = set() + for child in children: + child_parts = child.split("/") + parent_parts = parent_rel.split("/") if parent_rel else [] + if len(child_parts) == len(parent_parts) + 1: + my_children.add(child_parts[-1]) + + return sorted(my_children) + + +def _resolve_target_dir(base_dir, directory, direction): + if not directory or not (isinstance(directory, str) and directory.strip()): + raise ValueError("directory must be a non-empty string") + + path = os.path.join(base_dir, directory.strip()) if direction and direction.strip(): path = os.path.join(path, direction.strip()) @@ -42,7 +77,8 @@ def _resolve_target_dir(base_dir, directory, direction): def _list_image_files(target_dir): try: files = [ - f for f in sorted(os.listdir(target_dir)) + f + for f in sorted(os.listdir(target_dir)) if os.path.isfile(os.path.join(target_dir, f)) and os.path.splitext(f)[1].lower() in SUPPORTED_EXTENSIONS ] @@ -58,14 +94,12 @@ def _resize_image(image, target_w, target_h): return image, orig_w, orig_h if target_w > 0 and target_h == 0: - fw = target_w fh = max(1, int(orig_h * (target_w / orig_w))) - return image.resize((fw, fh), Image.Resampling.LANCZOS), fw, fh + return image.resize((target_w, fh), Image.Resampling.LANCZOS), target_w, fh if target_h > 0 and target_w == 0: - fh = target_h fw = max(1, int(orig_w * (target_h / orig_h))) - return image.resize((fw, fh), Image.Resampling.LANCZOS), fw, fh + return image.resize((fw, target_h), Image.Resampling.LANCZOS), fw, target_h scale = max(target_w / orig_w, target_h / orig_h) new_w = max(1, int(orig_w * scale)) @@ -98,7 +132,9 @@ class CompassImageLoader: RETURN_NAMES = ("IMAGE", "path", "width", "height", "frame_count") FUNCTION = "load_images" - def load_images(self, directory, direction, modality, frame=None, width=0, height=0): + def load_images( + self, directory, direction, modality, frame=None, width=0, height=0 + ): base_dir = folder_paths.get_input_directory() target_dir = _resolve_target_dir(base_dir, directory, direction) modality_path = os.path.join(target_dir, modality) @@ -110,7 +146,6 @@ class CompassImageLoader: if not files: raise RuntimeError(f"No images found in: {modality_path}") - # Frame selection if frame is None or str(frame).strip() == "": selected_files = files output_path = modality_path @@ -131,7 +166,6 @@ class CompassImageLoader: selected_files = [files[index]] output_path = os.path.join(modality_path, files[index]) - # Load and process images tensors = [] final_w, final_h = 0, 0 @@ -150,7 +184,9 @@ class CompassImageLoader: return (image_batch, output_path, final_w, final_h, len(selected_files)) @classmethod - def IS_CHANGED(cls, directory, direction, modality, frame=None, width=0, height=0): + def IS_CHANGED( + cls, directory, direction, modality, frame=None, width=0, height=0 + ): import hashlib base_dir = folder_paths.get_input_directory() @@ -162,7 +198,9 @@ class CompassImageLoader: files = _list_image_files(modality_path) m = hashlib.sha256() - m.update(f"{directory}|{direction}|{modality}|{frame}|{width}|{height}".encode()) + m.update( + f"{directory}|{direction}|{modality}|{frame}|{width}|{height}".encode() + ) if frame is None or str(frame).strip() == "": for f in files: diff --git a/js/compass_image_loader.js b/js/compass_image_loader.js index 7877d8a..e564f25 100644 --- a/js/compass_image_loader.js +++ b/js/compass_image_loader.js @@ -1,5 +1,4 @@ import { app } from "../../../scripts/app.js"; -import { api } from "../../../scripts/api.js"; function getAllNodesOfType(type) { const nodes = []; @@ -9,49 +8,32 @@ function getAllNodesOfType(type) { return nodes; } -function parseDirectorySegments(value) { - const parts = value.split("/").filter(Boolean); - return { - prefix: parts.length > 0 ? parts.slice(0, -1).join("/") : "", - filter: parts.length > 0 ? parts[parts.length - 1] : value, - isAtStart: !value.endsWith("/"), - }; +function filterChildDirs(allDirs, parentPrefix) { + const prefixNorm = parentPrefix.replace(/\/$/, "").toLowerCase(); + const results = []; + + for (const dir of allDirs) { + const parts = dir.split("/").filter(Boolean); + if (parts.length < 1) continue; + + const parent = parts.slice(0, -1).join("/").toLowerCase(); + const child = parts[parts.length - 1]; + + if (parent === prefixNorm) { + results.push(child); + } + } + + return [...new Set(results)].sort(); } -function filterDirectories(allDirs, prefix, filter) { - const prefixLower = prefix.toLowerCase(); - const filterLower = filter.toLowerCase(); - - return allDirs - .filter((d) => { - const dParts = d.split("/").filter(Boolean); - if (prefixLower) { - if (dParts.length < 2) return false; - const dirPrefix = dParts.slice(0, -1).join("/").toLowerCase(); - if (dirPrefix !== prefixLower) return false; - } else { - if (dParts.length < 1) return false; - } - const lastPart = dParts[dParts.length - 1].toLowerCase(); - return lastPart.startsWith(filterLower); - }) - .map((d) => { - const dParts = d.split("/").filter(Boolean); - return dParts[prefix ? dParts.length - 1 : dParts.length - 1]; - }); -} - -function uniqueSorted(arr) { - return [...new Set(arr)].sort(); -} - -function showDropdown(listEl, items, widget, inputEl) { +function showDropdown(listEl, items, directoryWidget, inputEl) { listEl.innerHTML = ""; if (items.length === 0) { listEl.style.display = "none"; return; } - items.forEach((item) => { + for (const item of items) { const li = document.createElement("li"); li.textContent = item; li.style.padding = "4px 8px"; @@ -59,29 +41,30 @@ function showDropdown(listEl, items, widget, inputEl) { li.style.color = "#ccc"; li.style.listStyle = "none"; li.addEventListener("mouseenter", () => { - document.querySelectorAll("#compass_dir_list li").forEach((el) => { - el.style.background = "transparent"; - }); + listEl.querySelectorAll("li").forEach((el) => (el.style.background = "transparent")); li.style.background = "#444"; }); li.addEventListener("click", () => { - const newValue = inputEl._prefix + item + "/"; + const newValue = inputEl._currentPrefix + item + "/"; inputEl.value = newValue; - inputEl._prefix = newValue; - widget.value = newValue; - listEl.innerHTML = ""; + inputEl._currentPrefix = newValue; + directoryWidget.value = newValue; + hideDropdown(listEl); inputEl.focus(); }); listEl.appendChild(li); + } + + Object.assign(listEl.style, { + display: "block", + background: "#222", + border: "1px solid #444", + position: "absolute", + zIndex: "9999", + width: inputEl.offsetWidth + "px", + maxHeight: "200px", + overflowY: "auto", }); - listEl.style.display = "block"; - listEl.style.background = "#222"; - listEl.style.border = "1px solid #444"; - listEl.style.position = "absolute"; - listEl.style.zIndex = "1000"; - listEl.style.width = inputEl.offsetWidth + "px"; - listEl.style.maxHeight = "200px"; - listEl.style.overflowY = "auto"; } function hideDropdown(listEl) { @@ -89,58 +72,6 @@ function hideDropdown(listEl) { listEl.style.display = "none"; } -function setupAutocomplete(node, directoryWidget, inputEl, listEl) { - inputEl._prefix = directoryWidget.value || ""; - inputEl._all_values = [...(directoryWidget._all_values || [])]; - - inputEl.addEventListener("input", () => { - const value = inputEl.value; - const { prefix, filter, isAtStart } = parseDirectorySegments(value); - - // Determine the effective prefix: use everything up to the last slash - let effectivePrefix = ""; - const lastSlash = value.lastIndexOf("/"); - if (lastSlash >= 0) { - effectivePrefix = value.substring(0, lastSlash + 1); - } - - const items = filterDirectories(inputEl._all_values, effectivePrefix, filter); - const unique = uniqueSorted(items); - showDropdown(listEl, unique, directoryWidget, inputEl); - - // Sync prefix back - inputEl._prefix = effectivePrefix; - }); - - inputEl.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - hideDropdown(listEl); - return; - } - if (e.key === "Enter" || e.key === "Tab") { - const visible = listEl.querySelectorAll("li"); - if (visible.length === 1) { - visible[0].click(); - e.preventDefault(); - } else if (visible.length > 0) { - // If user presses Tab with multiple options, pick the first match - visible[0].click(); - e.preventDefault(); - } else { - hideDropdown(listEl); - } - } - }); - - inputEl.addEventListener("blur", () => { - setTimeout(() => { - if (!listEl.contains(document.activeElement)) { - hideDropdown(listEl); - } - }, 150); - }); -} - app.registerExtension({ name: "CompassImageLoader", @@ -154,49 +85,112 @@ app.registerExtension({ const directoryWidget = this.widgets?.find((w) => w.name === "directory"); if (!directoryWidget) return; - // Build the full list from the node definition values const defDirs = nodeData.input?.required?.directory; - if (defDirs && Array.isArray(defDirs[0])) { - directoryWidget._all_values = defDirs[0]; - } else { - directoryWidget._all_values = []; - } + directoryWidget._all_values = defDirs && Array.isArray(defDirs[0]) ? defDirs[0] : []; - // Find or create the text input for filtering const filterWidget = this.widgets?.find((w) => w.name === "directory_filter"); const inputEl = filterWidget?.inputEl; + if (!inputEl) return; - if (inputEl) { - // Create dropdown list element - const listEl = document.createElement("ul"); - listEl.id = "compass_dir_list"; - listEl.style.display = "none"; - listEl.style.position = "absolute"; - listEl.style.zIndex = "9999"; - document.body.appendChild(listEl); + const listEl = document.createElement("ul"); + listEl.style.display = "none"; + document.body.appendChild(listEl); - inputEl._prefix = directoryWidget.value || ""; - setupAutocomplete(this, directoryWidget, inputEl, listEl); + inputEl._currentPrefix = directoryWidget.value || ""; - // When the original combo value changes from workflow load, sync - const origSetValue = directoryWidget.callback - ? directoryWidget.callback - : () => {}; - directoryWidget.callback = (value) => { - origSetValue.call(directoryWidget, value); - if (inputEl) { - inputEl._prefix = value || ""; - inputEl.value = value || ""; + inputEl.addEventListener("input", () => { + const val = inputEl.value; + const lastSlash = val.lastIndexOf("/"); + + let prefix, filter; + if (lastSlash >= 0) { + prefix = val.substring(0, lastSlash + 1); + filter = val.substring(lastSlash + 1); + } else { + prefix = ""; + filter = val; + } + + if (prefix) { + const cleanPrefix = prefix.replace(/\/$/, ""); + const candidates = filterChildDirs(directoryWidget._all_values, cleanPrefix); + const filtered = filter + ? candidates.filter((c) => c.toLowerCase().startsWith(filter.toLowerCase())) + : candidates; + showDropdown(listEl, filtered, directoryWidget, inputEl); + } else { + const filtered = filter + ? directoryWidget._all_values.filter((d) => + d.toLowerCase().includes(filter.toLowerCase()) + ) + : [...new Set(directoryWidget._all_values.map((d) => d.split("/")[0]))].sort(); + showDropdown(listEl, filtered, directoryWidget, inputEl); + } + }); + + inputEl.addEventListener("focus", () => { + const val = inputEl.value; + const lastSlash = val.lastIndexOf("/"); + + if (lastSlash >= 0 && val.endsWith("/")) { + const prefix = val.replace(/\/$/, ""); + inputEl._currentPrefix = prefix + "/"; + const items = filterChildDirs(directoryWidget._all_values, prefix); + showDropdown(listEl, items, directoryWidget, inputEl); + } else if (lastSlash >= 0) { + const prefix = val.substring(0, lastSlash + 1); + inputEl._currentPrefix = prefix; + inputEl.focus(); + return; + } else { + inputEl._currentPrefix = ""; + const items = filter + ? directoryWidget._all_values.filter((d) => + d.toLowerCase().includes(val.toLowerCase()) + ) + : [...new Set(directoryWidget._all_values.map((d) => d.split("/")[0]))].sort(); + showDropdown(listEl, items, directoryWidget, inputEl); + } + }); + + inputEl.addEventListener("blur", () => { + setTimeout(() => { + if (!listEl.contains(document.activeElement)) { + hideDropdown(listEl); } - }; + }, 150); + }); - // Cleanup when node is removed - const origOnRemoved = this.onRemoved; - this.onRemoved = () => { - if (origOnRemoved) origOnRemoved.apply(this, []); - listEl.remove(); - }; - } + inputEl.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + hideDropdown(listEl); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + const visible = listEl.querySelectorAll("li"); + if (visible.length === 1) { + visible[0].click(); + e.preventDefault(); + } else if (visible.length > 0) { + visible[0].click(); + e.preventDefault(); + } + hideDropdown(listEl); + } + }); + + const origSetValue = directoryWidget.callback ? directoryWidget.callback.bind(directoryWidget) : () => {}; + directoryWidget.callback = (value) => { + origSetValue(value); + inputEl._currentPrefix = value || ""; + inputEl.value = value || ""; + }; + + const origOnRemoved = this.onRemoved ? this.onRemoved.bind(this) : () => {}; + this.onRemoved = () => { + origOnRemoved(); + listEl.remove(); + }; }; }, @@ -204,14 +198,12 @@ app.registerExtension({ if (node.type !== "CompassImageLoader") return; const directoryWidget = node.widgets?.find((w) => w.name === "directory"); - if (!directoryWidget) return; - const filterWidget = node.widgets?.find((w) => w.name === "directory_filter"); const inputEl = filterWidget?.inputEl; const listEl = document.getElementById("compass_dir_list"); if (inputEl && listEl) { - setupAutocomplete(node, directoryWidget, inputEl, listEl); + inputEl._currentPrefix = directoryWidget?.value || ""; } }, });