From b477f2d7f3e0ecab4c95dcf59f57349f0e087cf4 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Sun, 1 Oct 2023 12:59:45 +0100 Subject: [PATCH] Add autocomplete to math expression Add min, max, randomchoice --- py/math_expression.py | 51 +++++++++++++++++++---- web/js/autocompleter.js | 57 ++++++++++++++++++++++---- web/js/common/autocomplete.js | 76 +++++++++++++++++++++++++++-------- 3 files changed, 153 insertions(+), 31 deletions(-) diff --git a/py/math_expression.py b/py/math_expression.py index abd4921..decebdc 100644 --- a/py/math_expression.py +++ b/py/math_expression.py @@ -15,25 +15,53 @@ operators = { ast.Mod: op.mod } +# TODO: restructure args to provide more info, generate hint based on args to save duplication functions = { "round": { "args": (1, 2), - "call": lambda a, b = None: round(a, b) + "call": lambda a, b = None: round(a, b), + "hint": "number, dp? = 0" }, "ceil": { "args": (1, 1), - "call": lambda a: math.ceil(a) + "call": lambda a: math.ceil(a), + "hint": "number" }, "floor": { "args": (1, 1), - "call": lambda a: math.floor(a) + "call": lambda a: math.floor(a), + "hint": "number" + }, + "min": { + "args": (2, None), + "call": lambda *args: min(*args), + "hint": "...numbers" + }, + "max": { + "args": (2, None), + "call": lambda *args: max(*args), + "hint": "...numbers" }, "randomint": { "args": (2, 2), - "call": lambda a, b: random.randint(a, b) - } + "call": lambda a, b: random.randint(a, b), + "hint": "min, max" + }, + "randomchoice": { + "args": (2, None), + "call": lambda *args: random.choice(args), + "hint": "...numbers" + }, } +autocompleteWords = list({ + "text": x, + "value": f"{x}()", + "showValue": False, + "hint": f"{functions[x]['hint']}", + "caretOffset": -1 +} for x in functions.keys()) + class MathExpression: @@ -41,7 +69,10 @@ class MathExpression: def INPUT_TYPES(cls): return { "required": { - "expression": ("STRING", {"multiline": True, "dynamicPrompts": False, "pysssss.autocomplete": False}), + "expression": ("STRING", {"multiline": True, "dynamicPrompts": False, "pysssss.autocomplete": { + "words": autocompleteWords, + "separator": "" + }}), }, "optional": { "a": ("INT,FLOAT,IMAGE,LATENT", ), @@ -131,9 +162,13 @@ class MathExpression: if node.func.id in functions: fn = functions[node.func.id] l = len(node.args) - if l < fn["args"][0] or l > fn["args"][1]: + if l < fn["args"][0] or (fn["args"][1] is not None and l > fn["args"][1]): + if fn["args"][1] is None: + toErr = " or more" + else: + toErr = f" to {fn['args'][1]}" raise SyntaxError( - f"Invalid function call: {node.func.id} requires {fn['args'][0]} to {fn['args'][1]} arguments") + f"Invalid function call: {node.func.id} requires {fn['args'][0]}{toErr} arguments") args = [] for arg in node.args: args.append(eval_expr(arg)) diff --git a/web/js/autocompleter.js b/web/js/autocompleter.js index cae1dbd..46ad35f 100644 --- a/web/js/autocompleter.js +++ b/web/js/autocompleter.js @@ -275,17 +275,43 @@ app.registerExtension({ ComfyWidgets.STRING = function (node, inputName, inputData) { const r = STRING.apply(this, arguments); - if (inputData[1]?.multiline && inputData[1]?.["pysssss.autocomplete"] !== false) { + if (inputData[1]?.multiline) { + // Disabled on this input + const config = inputData[1]?.["pysssss.autocomplete"]; + if (config === false) return r; + + // In list of widgets to skip const id = `${node.comfyClass}.${inputName}`; - if (!SKIP_WIDGETS.has(id)) { - new TextAreaAutoComplete(r.widget.inputEl); + if (SKIP_WIDGETS.has(id)) return r; + + let words; + let separator; + if (typeof config === "object") { + separator = config.separator; + words = {}; + if (config.words) { + // Custom wordlist, this will have been registered on setup + Object.assign(words, TextAreaAutoComplete.groups[node.comfyClass + "." + inputName] ?? {}); + } + + for (const item of config.groups ?? []) { + if (item === "*") { + // This widget wants all global words included + Object.assign(words, TextAreaAutoComplete.globalWords); + } else { + // This widget wants a specific group included + Object.assign(words, TextAreaAutoComplete.groups[item] ?? {}); + } + } } + + new TextAreaAutoComplete(r.widget.inputEl, words, separator); } return r; }; - TextAreaAutoComplete.separator = localStorage.getItem(id + ".AutoSeparate") ?? ", "; + TextAreaAutoComplete.globalSeparator = localStorage.getItem(id + ".AutoSeparate") ?? ", "; app.ui.settings.addSetting({ id, name: "🐍 Text Autocomplete", @@ -332,11 +358,11 @@ app.registerExtension({ $el("input", { id: id.replaceAll(".", "-"), type: "checkbox", - checked: !!TextAreaAutoComplete.separator, + checked: !!TextAreaAutoComplete.globalSeparator, onchange: (event) => { const checked = !!event.target.checked; - TextAreaAutoComplete.separator = checked ? ", " : ""; - localStorage.setItem(id + ".AutoSeparate", TextAreaAutoComplete.separator); + TextAreaAutoComplete.globalSeparator = checked ? ", " : ""; + localStorage.setItem(id + ".AutoSeparate", TextAreaAutoComplete.globalSeparator); }, }), ] @@ -358,4 +384,21 @@ app.registerExtension({ }, }); }, + beforeRegisterNodeDef(_, def) { + // Process each input to see if there is a custom word list for + // { input: { required: { something: ["STRING", { "pysssss.autocomplete": ["groupid", ["custom", "words"] ] }] } } } + const inputs = { ...def.input?.required, ...def.input?.optional }; + for (const input in inputs) { + const config = inputs[input][1]?.["pysssss.autocomplete"]; + if (!config) continue; + if (typeof config === "object" && config.words) { + const words = {}; + for (const text of config.words || []) { + const obj = typeof text === "string" ? { text } : text; + words[obj.text] = obj; + } + TextAreaAutoComplete.updateWords(def.name + "." + input, words, false); + } + } + }, }); diff --git a/web/js/common/autocomplete.js b/web/js/common/autocomplete.js index 266fbb4..096b58c 100644 --- a/web/js/common/autocomplete.js +++ b/web/js/common/autocomplete.js @@ -284,14 +284,14 @@ class TextAreaCaretHelper { return this.el.value.substring(this.el.selectionEnd); } - insertAtCursor(value, offset) { + insertAtCursor(value, offset, finalOffset) { if (this.el.selectionStart != null) { const startPos = this.el.selectionStart; const endPos = this.el.selectionEnd; this.el.value = this.el.value.substring(0, startPos + offset) + value + this.el.value.substring(endPos, this.el.value.length); - this.el.selectionEnd = this.el.selectionStart = startPos + value.length + offset; + this.el.selectionEnd = this.el.selectionStart = startPos + value.length + offset + finalOffset; } else { this.el.value += value; } @@ -300,25 +300,51 @@ class TextAreaCaretHelper { /*********************/ +/** + * @typedef {{ + * text: string, + * priority?: number, + * info?: Function, + * hint?: string, + * showValue?: boolean, + * caretOffset?: number + * }} AutoCompleteEntry + */ export class TextAreaAutoComplete { - static separator = ""; + static globalSeparator = ""; static enabled = true; + /** @type {Record>} */ static groups = {}; - - /** @type {Record} */ - static words = {}; + /** @type {Set} */ + static globalGroups = new Set(); + /** @type {Record} */ + static globalWords = {}; /** @type {HTMLTextAreaElement} */ el; + /** @type {Record} */ + overrideWords; + overrideSeparator = ""; + + get words() { + return this.overrideWords ?? TextAreaAutoComplete.globalWords; + } + + get separator() { + return this.overrideSeparator ?? TextAreaAutoComplete.globalSeparator; + } + /** * @param {HTMLTextAreaElement} el */ - constructor(el) { + constructor(el, words = null, separator = null) { this.el = el; this.helper = new TextAreaCaretHelper(el); this.dropdown = $el("div.pysssss-autocomplete"); + this.overrideWords = words; + this.overrideSeparator = separator; this.#setup(); } @@ -424,7 +450,7 @@ export class TextAreaAutoComplete { const priorityMatches = []; const prefixMatches = []; const includesMatches = []; - for (const word of Object.keys(TextAreaAutoComplete.words)) { + for (const word of Object.keys(this.words)) { const lowerWord = word.toLocaleLowerCase(); if (lowerWord === term) { // Dont include exact matches @@ -437,7 +463,7 @@ export class TextAreaAutoComplete { continue; } - const wordInfo = TextAreaAutoComplete.words[word]; + const wordInfo = this.words[word]; if (wordInfo.priority) { priorityMatches.push({ pos, wordInfo }); } else if (pos) { @@ -499,6 +525,14 @@ export class TextAreaAutoComplete { }), ]; + if (wordInfo.hint) { + parts.push( + $el("span.pysssss-autocomplete-pill", { + textContent: wordInfo.hint, + }) + ); + } + if (wordInfo.priority) { parts.push( $el("span.pysssss-autocomplete-pill", { @@ -507,7 +541,7 @@ export class TextAreaAutoComplete { ); } - if (wordInfo.value && wordInfo.text !== wordInfo.value) { + if (wordInfo.value && wordInfo.text !== wordInfo.value && wordInfo.showValue !== false) { parts.push( $el("span.pysssss-autocomplete-pill", { textContent: wordInfo.value, @@ -534,8 +568,9 @@ export class TextAreaAutoComplete { onclick: () => { this.el.focus(); this.helper.insertAtCursor( - (wordInfo.value ?? wordInfo.text) + TextAreaAutoComplete.separator, - -before.length + (wordInfo.value ?? wordInfo.text) + this.separator, + -before.length, + wordInfo.caretOffset ); setTimeout(() => { this.#update(); @@ -575,15 +610,24 @@ export class TextAreaAutoComplete { this.dropdown.remove(); } - static updateWords(id, words) { + static updateWords(id, words, addGlobal = true) { const isUpdate = id in TextAreaAutoComplete.groups; TextAreaAutoComplete.groups[id] = words; + if (addGlobal) { + TextAreaAutoComplete.globalGroups.add(id); + } + if (isUpdate) { // Remerge all words - TextAreaAutoComplete.words = Object.assign({}, ...Object.values(TextAreaAutoComplete.groups)); - } else { + TextAreaAutoComplete.globalWords = Object.assign( + {}, + ...Object.keys(TextAreaAutoComplete.groups) + .filter((k) => TextAreaAutoComplete.globalGroups.has(k)) + .map((k) => TextAreaAutoComplete.groups[k]) + ); + } else if (addGlobal) { // Just insert the new words - Object.assign(TextAreaAutoComplete.words, words); + Object.assign(TextAreaAutoComplete.globalWords, words); } } }