diff --git a/app/ui/composer/elements/input.js b/app/ui/composer/elements/input.js index 61879ff0..f02716d6 100644 --- a/app/ui/composer/elements/input.js +++ b/app/ui/composer/elements/input.js @@ -84,9 +84,16 @@ define("number", "md", ({ value = 0, min, max, step = 1, precision, unit, onChan }; // "change" is the browser's commit: each spinner-arrow click, the wheel, and leaving a typed edit. Blur alone missed // the arrows, so a redraw put the old value back. Typing does not fire it, so mid-typing values stay typeable. - input.addEventListener("change", commit); - input.addEventListener("blur", commit); - input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } }); + // Keyboard arrows count as typing (committed on Enter or leaving): committed per press, a panel that redraws on a change + // would rebuild the field under the caret and the second press would go nowhere. + let keyed = false; + input.addEventListener("change", () => { if (keyed) { keyed = false; return; } commit(); }); + input.addEventListener("blur", () => { keyed = false; commit(); }); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); keyed = false; commit(); } + else if (/^(ArrowUp|ArrowDown|PageUp|PageDown)$/.test(e.key)) keyed = true; + }); + input.addEventListener("pointerdown", () => { keyed = false; }); const node = unit ? el("span", { cls: "cx-number-wrap", children: [input, el("span", { cls: "cx-unit", text: unit })] }) diff --git a/app/ui/composer/tests/elements_behaviour.test.js b/app/ui/composer/tests/elements_behaviour.test.js index 8d149cb5..ec4b80af 100644 --- a/app/ui/composer/tests/elements_behaviour.test.js +++ b/app/ui/composer/tests/elements_behaviour.test.js @@ -353,6 +353,12 @@ test("a spinner-arrow click commits at once; leaving afterwards does not save it assert.deepEqual(seen, [4]); fire(input, "blur"); assert.deepEqual(seen, [4]); + input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); + input.value = "5"; + fire(input, "change"); + assert.deepEqual(seen, [4], "a keyboard arrow is typing: not committed per press"); + fire(input, "blur"); + assert.deepEqual(seen, [4, 5]); }); test("a number field ignores unparseable text on commit", () => { diff --git a/app/ui/features/inspector/hub.test.js b/app/ui/features/inspector/hub.test.js index 008f5ff0..49cbead5 100644 --- a/app/ui/features/inspector/hub.test.js +++ b/app/ui/features/inspector/hub.test.js @@ -27,3 +27,22 @@ test("a scene's text changed elsewhere (the Story box) shows in the Prompt box, await new Promise((r) => setTimeout(r, 0)); assert.equal(box().value, "while typing"); // caught up once the focus left }); + +test("typing in the Prompt box, then pressing a button in the panel, does not rebuild the button out from under the click", async () => { + let fire; + const doc = { id: "P", frame_rate: 25, num_frames_per_scene: 49, scenes: [{ id: "a", text: "old" }], scene_renders: {} }; + const p = { project: doc, get scenes() { return doc.scenes; }, get selected() { return doc.scenes[0]; }, selectedId: "a", + setText: () => {}, setScene: () => {}, edit: (fn) => fn(doc) }; + const host = document.createElement("div"); + document.body.append(host); + hub.setup({ host, app: { project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } }); + const box = host.querySelector("textarea"); + box.focus(); + doc.scenes[0].text = "typed here"; fire("change"); // the box's own edit, as setText would announce it + const button = box.closest("[aria-label=Properties]") ? [...box.closest("[aria-label=Properties]").querySelectorAll("button, input[type=checkbox]")].find((b) => b !== box) : null; + assert.ok(button, "a control inside the panel body"); + button.focus(); // the mouse-down moves focus to it + await new Promise((r) => setTimeout(r, 0)); + assert.ok(button.isConnected, "the pressed button is still there for its mouse-up"); + host.remove(); +}); diff --git a/app/ui/features/inspector/index.js b/app/ui/features/inspector/index.js index 4ab21c8c..479eeca9 100644 --- a/app/ui/features/inspector/index.js +++ b/app/ui/features/inspector/index.js @@ -28,7 +28,9 @@ export default { // own box is being typed in: that would rebuild it under the caret. It catches up when the focus leaves. let shownText; const typing = () => body.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName); - body.node.addEventListener("focusout", () => setTimeout(() => { if (!typing()) draw(); })); + // Catch up once focus leaves the panel. Focus moving to a button or select inside it is a click under way: rebuilding + // between its mouse-down and mouse-up dropped the click; the change it makes redraws afterwards anyway. + body.node.addEventListener("focusout", (e) => { if (!(e.relatedTarget && body.node.contains(e.relatedTarget))) setTimeout(() => { if (!typing()) draw(); }); }); function draw(force) { // not on every keystroke: that would rebuild the box being typed in const sc = p.selected, open = p.project; const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "project" && open ? [open.postfix_enabled !== false, open.generation_mode, open.export_size_from, open.width, open.height, open.scenes.map((s) => s.frames_mode === "custom"), Object.keys(open.scene_renders || {}), open.scenes.length] : ""}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length, sc.source_image, (sc.references || []).join(), sc.source_in, sc.source_dur, sc.removed_from_plan, (sc.source || {}).type, sc.excluded, typing() ? shownText : (shownText = sc.text), ((open.scene_renders || {})[sc.id] || {}).durationSec, Boolean(((open.scene_renders || {})[sc.id] || {}).media), bin.version, ...sections.map((s) => { try { return s.key(sc, open); } catch { return ""; } })] : ""}`; diff --git a/app/ui/features/peek/index.js b/app/ui/features/peek/index.js index 6cf1fb0f..316e3693 100644 --- a/app/ui/features/peek/index.js +++ b/app/ui/features/peek/index.js @@ -24,7 +24,7 @@ export default { const paint = () => { root.classList.toggle("fp-peek", on); place(); const z = zone(); if (sizer && z) sizer.observe(z); }; paint(); const tab = c.button.sm({ label: "Auto-hide", tone: "neutral", pressed: on, title: "Show the timeline only while the pointer is on it", onClick: () => { - on = !on; paint(); tab.setPressed && tab.setPressed(on); + on = !on; clearTimeout(timer); timer = null; root.classList.remove("fp-peek-open"); paint(); tab.setPressed && tab.setPressed(on); try { on ? localStorage.setItem(KEY, "1") : localStorage.removeItem(KEY); } catch { /* lasts until reload */ } } }); host.append(tab.node); diff --git a/app/ui/features/preview/index.js b/app/ui/features/preview/index.js index 01929474..51d01863 100644 --- a/app/ui/features/preview/index.js +++ b/app/ui/features/preview/index.js @@ -106,7 +106,7 @@ export default { const v = viewer.element; if (seg && v && !v.paused) onTime(v); // timeupdate is too slow to catch a short clip's end else head.set(head.at + 0.04); - if (head.at >= total()) { pause(); head.set(Math.min(head.at, total())); } + if (head.at >= total()) { head.set(Math.min(head.at, total())); pause(); } // back on the end first: past it there is no clip, and the picture would drop and reload }, 40); function start() { if (!total()) return; if (head.at >= total() - 0.02) head.set(0); playing = true; sync(); } function pause() { playing = false; sync(); } diff --git a/app/ui/features/rating/index.js b/app/ui/features/rating/index.js index a9a79a23..1130f53d 100644 --- a/app/ui/features/rating/index.js +++ b/app/ui/features/rating/index.js @@ -4,6 +4,15 @@ import { inPlace } from "../../shell/place.js"; import { genUnitId, isGenerative, unitRoot } from "../../shell/scenes.js"; import { CHOICES, FORGET, nameOf, tasteOf } from "./choices.js"; +/** What to say when a rating taught nothing, or "" when it did. The run captured nothing, a learner is on and no key is + * set: the missing key is why. Any other reason is the server's own (the key on screen now may not be the run's). */ +export function notTaught(r, ps) { + if (!(r && r.why)) return ""; + const taste = ps.modulesById && ps.modulesById().taste; + const keyless = r.reason === "nothing" && taste && ps.useful(taste) && !String((ps.currentValues().taste || {}).key || "").trim(); + return keyless ? "Taste not taught: no Taste key is set, so nothing learns from ratings. Name one in Settings ▸ Engine ▸ System ▸ Taste key." : `Taste not taught: ${r.why}.`; +} + export default { id: "rating", mount: "timeline.actions", @@ -27,9 +36,8 @@ export default { const body = taste === "clear" ? { rating: null, axis: null } : taste; try { const r = await app.api.rateTaste(t.render.promptId, body.rating, body.axis); - // with no key nothing is ever captured: that, not the server's list of other causes, is the reason - const keyless = !String((app.pipeline.currentValues().taste || {}).key || "").trim(); - if (r && r.why) c.toast.warn({ text: keyless ? "Taste not taught: no Taste key is set, so nothing learns from ratings. Name one in Settings ▸ Engine ▸ System ▸ Taste key." : `Taste not taught: ${r.why}.` }); + const text = notTaught(r, app.pipeline); + if (text) c.toast.warn({ text }); } catch { /* no taste module here: the rating stays a label */ } }; const draw = () => { diff --git a/app/ui/features/rating/rating.test.js b/app/ui/features/rating/rating.test.js new file mode 100644 index 00000000..d71d6644 --- /dev/null +++ b/app/ui/features/rating/rating.test.js @@ -0,0 +1,14 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { notTaught } from "./index.js"; + +const ps = (key, learning = true) => { const taste = { id: "taste" }; return { modulesById: () => ({ taste }), useful: () => learning, currentValues: () => ({ taste: { key } }) }; }; + +test("the not-taught reason blames a missing key only when the run captured nothing, a learner is on, and no key is set", () => { + const nothing = { why: "this run captured nothing", reason: "nothing" }, forgotten = { why: "a new Generate started", reason: "forgotten" }; + assert.match(notTaught(nothing, ps("")), /no Taste key is set/); + assert.match(notTaught(forgotten, ps("")), /new Generate started/, "the key cleared after the run is not why"); + assert.match(notTaught(nothing, ps("", false)), /captured nothing/, "nothing learns anyway: a key would not have helped"); + assert.match(notTaught(nothing, ps("portraits")), /captured nothing/); + assert.equal(notTaught({ recorded: ["vel"], why: null }, ps("")), ""); +}); diff --git a/modules/system/taste/store.py b/modules/system/taste/store.py index 683dca76..537a1103 100644 --- a/modules/system/taste/store.py +++ b/modules/system/taste/store.py @@ -262,8 +262,9 @@ def rate(prompt_id, rating, axis=None): return out if not entry: + out["reason"] = "forgotten" if prompt_id in state.get("forgot", []) else "nothing" # a label the app can act on; `why` is for people out["why"] = ("a new Generate started before this clip was rated, and unrated clips are forgotten then" - if prompt_id in state.get("forgot", []) else + if out["reason"] == "forgotten" else "this run captured nothing: no learning feature acted on it, or ComfyUI reused an " "earlier result instead of sampling again") return out diff --git a/modules/system/taste/tests/test_taste.py b/modules/system/taste/tests/test_taste.py index 76a73fd7..40a94c98 100644 --- a/modules/system/taste/tests/test_taste.py +++ b/modules/system/taste/tests/test_taste.py @@ -200,8 +200,9 @@ def test_a_clip_that_captured_nothing_is_not_blamed_on_a_later_generate(): store.capture("k", "vel", {"a": torch.zeros(2)}, prompt_id="p1") store.new_generation() store.capture("k", "vel", {"a": torch.zeros(2)}, prompt_id="p2") # p2 ran and kept p1's forgetting on record - assert "new Generate" in store.rate("p1", "liked")["why"] - assert "captured nothing" in store.rate("p3", "liked")["why"] + first, third = store.rate("p1", "liked"), store.rate("p3", "liked") + assert "new Generate" in first["why"] and first["reason"] == "forgotten" + assert "captured nothing" in third["why"] and third["reason"] == "nothing" def test_deleting_a_key_leaves_other_keys_runs_ratable_and_one_run_can_wait_under_two_keys():