Models and pipeline, Updates, Node packs, the log, and Temp files were five separate dropdown entries with no way to search across them. One button now opens a single searchable window; About is drawn in place, everything else is a deep link -- each of those five already has real, tested modal chrome of its own, so this reuses it rather than rebuilding it inline. Uses the existing composer.splitPane.h for the two-pane layout rather than a new element -- an earlier pass added one, and its CSS collided with splitPane's own .cx-split class, silently breaking the pipeline window's own list/parameters split (caught by pipeline.spec.js before this landed).
143 lines
5.8 KiB
JavaScript
143 lines
5.8 KiB
JavaScript
// The menu bar: who this is, what is not a zone, and whether the server is
|
|
// there.
|
|
//
|
|
// v4's arrangement, and the reason an app with only zones reads as unfinished:
|
|
// a zone head holds what acts on THAT zone, so anything that acts on the app --
|
|
// a window, a preference, the state of the connection -- has nowhere to live
|
|
// unless there is a bar that belongs to the app itself.
|
|
//
|
|
// Only menus with something real in them exist. An empty File menu is a promise
|
|
// the app does not keep, and this project's rule for an inapplicable feature is
|
|
// that it is absent rather than greyed.
|
|
|
|
import { composer } from "../composer/composer.js";
|
|
import { LIVE, LOST } from "./run.js";
|
|
import { services } from "./services.js";
|
|
|
|
const THEMES = [
|
|
{ id: "dark", label: "Dark" },
|
|
{ id: "light", label: "Light" },
|
|
{ id: "auto", label: "Auto", hint: "Follows the system" },
|
|
];
|
|
|
|
/** A dot and a word for whether ComfyUI is still on the other end. */
|
|
export function createConnection() {
|
|
const dot = composer.dot.default({ tone: "neutral", label: "ComfyUI" });
|
|
const text = composer.text.xs({ text: "Connecting" });
|
|
|
|
const SAID = {
|
|
[LIVE]: { tone: "good", words: "ComfyUI live" },
|
|
[LOST]: { tone: "danger", words: "Reconnecting" },
|
|
};
|
|
|
|
return {
|
|
/** Two handles, not a wrapper: the bar it goes in already lays out a row. */
|
|
items: [dot, text],
|
|
dot, text,
|
|
/** Reads the same run state everything else does. */
|
|
draw(state) {
|
|
const said = SAID[state && state.connection] || { tone: "neutral", words: "Connecting" };
|
|
dot.node.className = `cx-dot cx-dot-${said.tone}`;
|
|
dot.node.setAttribute("aria-label", said.words);
|
|
text.setText(said.words);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* menu(label, items, onPick) -> a button that opens them.
|
|
*
|
|
* The menu is built on each press rather than kept: what is in it depends on
|
|
* what is true when it is opened -- which panel is showing, which theme is on
|
|
* -- and a menu built once is a menu that goes stale the first time anything
|
|
* changes underneath it.
|
|
*/
|
|
function menu(label, itemsOf, onPick) {
|
|
let open = null;
|
|
const button = composer.button.sm({
|
|
label, tone: "ghost",
|
|
onClick: () => {
|
|
if (open) { open.close("toggle"); open = null; return; }
|
|
open = composer.menu.dropdown({
|
|
anchor: button, side: "bottom", align: "start",
|
|
items: itemsOf(),
|
|
onPick: (id) => { open = null; onPick(id); },
|
|
onClose: () => { open = null; },
|
|
});
|
|
},
|
|
});
|
|
return button;
|
|
}
|
|
|
|
export function createMenubar({ workspace, onProject, projects = () => [],
|
|
current = () => null, edits = null, onSettings,
|
|
theme = services.theme } = {}) {
|
|
const connection = createConnection();
|
|
|
|
const file = menu("File", () => [
|
|
{ id: "new", label: "New project…" },
|
|
{ separator: true },
|
|
// Moving a project between machines -- a git checkout does not carry
|
|
// ~/Documents/ComfyUI/user/default/funpack_projects with it.
|
|
{ id: "save-file", label: "Save Project File…", hint: "⬇" },
|
|
{ id: "load-file", label: "Load Project File…" },
|
|
...(projects().length ? [{ separator: true }] : []),
|
|
// A tick rather than leaving the current one out: which project is open is
|
|
// a fact about the app, and a menu that hides it makes you press one to find out.
|
|
...projects().map((p) => ({ id: p.id, label: p.name, icon: p.id === current() ? "✓" : " " })),
|
|
], (id) => { if (onProject) onProject(id); });
|
|
|
|
// Built on each press, so "Undo" is offered only when there is something to
|
|
// undo -- a menu that always offers it teaches people it does nothing.
|
|
const edit = edits && menu("Edit", () => [
|
|
{ id: "undo", label: "Undo", hint: "⌘Z", disabled: !edits.canUndo() },
|
|
{ id: "redo", label: "Redo", hint: "⇧⌘Z", disabled: !edits.canRedo() },
|
|
{ separator: true },
|
|
{ id: "scene", label: "Add scene" },
|
|
{ id: "remove", label: "Delete scene" },
|
|
// Dropped from a batch run without leaving the timeline -- a scene kept
|
|
// for later, not one thrown away.
|
|
{ id: "exclude", label: edits.excluded() ? "Include Scene" : "Exclude Scene" },
|
|
{ separator: true },
|
|
{ id: "earlier", label: "Move clip left" },
|
|
{ id: "later", label: "Move clip right" },
|
|
], (id) => edits.run(id));
|
|
|
|
const view = menu("View", () => [
|
|
{ id: "left", label: `${workspace && workspace.isOpen("left") ? "Hide" : "Show"} Assets` },
|
|
{ id: "right", label: `${workspace && workspace.isOpen("right") ? "Hide" : "Show"} Properties` },
|
|
{ separator: true },
|
|
{ id: "reset", label: "Reset layout" },
|
|
{ separator: true },
|
|
...THEMES.map((t) => ({
|
|
...t,
|
|
// A tick rather than a disabled row: which one is on is a fact about the
|
|
// app, and a menu that hides it makes the user press one to find out.
|
|
icon: theme.get() === t.id ? "✓" : " ",
|
|
})),
|
|
], (id) => {
|
|
if (id === "left" || id === "right") workspace.toggle(id);
|
|
// Cheap insurance: a person who has closed both panels and forgotten which
|
|
// control brings them back has no way home otherwise.
|
|
else if (id === "reset") { workspace.open("left"); workspace.open("right"); }
|
|
else theme.set(id);
|
|
});
|
|
|
|
// One window, not five menu items: Models and pipeline, Updates, Node
|
|
// packs, the log and Temp files all live inside it now, searchable by
|
|
// name -- a button straight to it, not a dropdown over a single choice.
|
|
const settings = composer.button.sm({
|
|
label: "Settings", tone: "ghost",
|
|
onClick: () => { if (onSettings) onSettings(); },
|
|
});
|
|
|
|
const bar = composer.toolbar.default({
|
|
label: "FunPack",
|
|
items: [composer.brand.default({ name: "FunPack" }),
|
|
...[file, edit, view, settings].filter(Boolean)],
|
|
trailing: connection.items,
|
|
});
|
|
|
|
return { node: bar.node, bar, connection, file, edit, view, settings };
|
|
}
|