diff --git a/app/ui/boot.js b/app/ui/boot.js index 882d3694..3e7c22a3 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -29,8 +29,9 @@ linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if (what === "o const frame = build(document.getElementById("app")); const selection = createSelection({ project }); selection.on(() => say("select")); -const app = { editorSettings: [], inputHooks: [], lastRun: { typed: undefined, n: 0 }, project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } }; +const app = { editorSettings: [], actions: [], inputHooks: [], lastRun: { typed: undefined, n: 0 }, project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } }; const settings = createSettings({ menubar: hostFor("menubar.menus") }); +app.openSettings = (id) => settings.open(id); const result = await loadFeatures(paths, { app }); settings.addButton(); app.project.start().catch((err) => console.warn("could not open a project:", err.message)); diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 24f2fc25..85d257e6 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -4,6 +4,7 @@ import { isGenerative, isVideoClip, genUnitId, unitRoot, effFrames, effFps } fro import { QUEUED, RUNNING } from "../../shell/run.js"; import { inPlace } from "../../shell/place.js"; import { buildInputs } from "./inputs.js"; +import { offer } from "../../shell/actions.js"; const expand = (body) => fetch("/funpack/api/prompt/expand", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...body, seed: Math.floor(Math.random() * 2 ** 31) || 1 }) }).then((r) => (r.ok ? r.json() : null)); @@ -129,6 +130,8 @@ export default { draw(); const offRun = g.subscribe(draw); const offApp = app.on((what) => { if (what === "generate.selected" && !busy && p.selected) runUnits(unitsOf([p.selected])); else draw(); }); - return () => { offRun(); offApp(); }; + const offers = [offer(app, { id: "generate-all", label: "Generate", icon: "▶", run: () => { if (!busy && p.project) runUnits(unitsOf(p.scenes)); } }), + offer(app, { id: "generate-selected", label: "Generate selected", icon: "▶", run: () => { if (!busy && p.selected) runUnits(unitsOf([p.selected])); } })]; + return () => { offRun(); offApp(); offers.forEach((f) => f()); }; }, }; diff --git a/app/ui/features/montage/index.js b/app/ui/features/montage/index.js index c4374ef7..8b984811 100644 --- a/app/ui/features/montage/index.js +++ b/app/ui/features/montage/index.js @@ -1,4 +1,5 @@ // Auto Montage: the button in the timeline header and its window. +import { offer } from "../../shell/actions.js"; import { composer as c } from "../../composer/composer.js"; import { playable, build } from "./build.js"; @@ -41,6 +42,8 @@ export default { } const draw = () => button.setDisabled(!p.project); draw(); - return app.on(draw); + const offered = offer(app, { id: "auto-montage", label: "Auto Montage", icon: "⚡", run: () => { if (!button.node.disabled) ask(); } }); + const off = app.on(draw); + return () => { off(); offered(); }; }, }; diff --git a/app/ui/features/render/index.js b/app/ui/features/render/index.js index 6e4e72c5..bfd3e0ca 100644 --- a/app/ui/features/render/index.js +++ b/app/ui/features/render/index.js @@ -1,4 +1,5 @@ // Render: stitch the cut into one file and keep it in the media bin. +import { offer } from "../../shell/actions.js"; import { composer as c } from "../../composer/composer.js"; import { call } from "../../shell/api.js"; import { clipSpecs } from "./clips.js"; @@ -33,6 +34,8 @@ export default { node = next; } draw(); - return app.on(() => { if (Boolean(p.project) === node.disabled) draw(); }); + const offered = offer(app, { id: "render-final", label: "Render", icon: "⧉", run: () => { if (!busy && p.project) render(); } }); + const off = app.on(() => { if (Boolean(p.project) === node.disabled) draw(); }); + return () => { off(); offered(); }; }, }; diff --git a/app/ui/features/sampler/index.js b/app/ui/features/sampler/index.js index bafed0da..78a3cbec 100644 --- a/app/ui/features/sampler/index.js +++ b/app/ui/features/sampler/index.js @@ -1,5 +1,6 @@ // "⏱ Sampler": steps, scheduler and sampler one click away, in a thin window over the timeline. Same settings as Engine ▸ Sampling, // same values tree, so the two cannot disagree. Absent when no installed module has a sampling setting. +import { offer } from "../../shell/actions.js"; import { composer as c } from "../../composer/composer.js"; import { settingRow, whenSatisfied } from "../settings/field.js"; @@ -34,6 +35,7 @@ export default { draw(); pop = c.popover.anchored({ anchor: button, body, side: "top", align: "end", onClose: () => { pop = null; } }); } - return () => { off(); offShow(); if (pop) pop.close(); button.node.remove(); }; + const offered = offer(app, { id: "sampler", label: "Sampler", icon: "⏱", run: () => { if (!button.node.hidden) (pop ? pop.close() : open()); } }); + return () => { off(); offShow(); offered(); if (pop) pop.close(); button.node.remove(); }; }, }; diff --git a/app/ui/features/wheel/index.js b/app/ui/features/wheel/index.js new file mode 100644 index 00000000..851d517e --- /dev/null +++ b/app/ui/features/wheel/index.js @@ -0,0 +1,27 @@ +// The picker wheel: press the middle mouse button anywhere and every offered action fans out under the pointer; release on one to run it. +import { composer as c } from "../../composer/composer.js"; +import { offer } from "../../shell/actions.js"; + +export default { + id: "wheel", + mount: "menubar.menus", + needs: ["project"], + setup({ app }) { + let wheel = null; + const offs = [ + offer(app, { id: "undo", label: "Undo", icon: "↶", run: () => app.project.undo() }), + offer(app, { id: "redo", label: "Redo", icon: "↷", run: () => app.project.redo() }), + offer(app, { id: "models", label: "Models & Pipeline", icon: "▤", run: () => app.openSettings && app.openSettings("models") }), + offer(app, { id: "engine", label: "Engine settings", icon: "⚙", run: () => app.openSettings && app.openSettings("engine") }), + ]; + const onDown = (e) => { + if (e.button !== 1 || wheel || document.querySelector('[role="dialog"]')) return; // nothing behind a dialog + e.preventDefault(); + const items = (app.actions || []).map((a) => ({ id: a.id, label: a.label, icon: a.icon })); + wheel = c.wheel.picker({ items, x: e.clientX, y: e.clientY, onClose: () => { wheel = null; }, + onPick: (id) => { const a = (app.actions || []).find((x) => x.id === id); if (a) Promise.resolve().then(() => a.run()).catch((err) => c.toast.warn({ text: err.message })); } }); + }; + document.addEventListener("mousedown", onDown, true); + return () => { document.removeEventListener("mousedown", onDown, true); if (wheel) wheel.close && wheel.close(); offs.forEach((f) => f()); }; + }, +}; diff --git a/app/ui/modules.js b/app/ui/modules.js index 2b92afe0..507f2d65 100644 --- a/app/ui/modules.js +++ b/app/ui/modules.js @@ -21,6 +21,7 @@ export default [ "./features/ideas/index.js", "./features/wizard/index.js", "./features/tour/index.js", + "./features/wheel/index.js", "./features/status/index.js", "./features/audio/index.js", "./features/montage/index.js", diff --git a/app/ui/shell/actions.js b/app/ui/shell/actions.js new file mode 100644 index 00000000..5e5b0de7 --- /dev/null +++ b/app/ui/shell/actions.js @@ -0,0 +1,7 @@ +// Things the app can be asked to do, by name: whoever builds a control offers it here, whoever shows a list (the wheel) reads what is there. +// A feature that failed to load offers nothing and is simply not among them. +export function offer(app, action) { + const list = app.actions || (app.actions = []); + list.push(action); + return () => { const i = list.indexOf(action); if (i >= 0) list.splice(i, 1); }; +}