diff --git a/app/ui/boot.js b/app/ui/boot.js index 050ec18e..ccb7ae6a 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -24,7 +24,7 @@ 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 = { project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead() }; +const app = { project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme }; const settings = createSettings({ menubar: hostFor("menubar.menus") }); const result = await loadFeatures(paths, { app }); settings.addButton(); diff --git a/app/ui/composer/elements/input.css b/app/ui/composer/elements/input.css index 3ba3f70f..0a248ed9 100644 --- a/app/ui/composer/elements/input.css +++ b/app/ui/composer/elements/input.css @@ -56,6 +56,7 @@ background: transparent; color: var(--text); font-size: var(--fs-5); text-align: left; cursor: pointer; width: 100%; } +.cx-filter-gap { block-size: var(--sp-5); } .cx-filter-row:hover { background: var(--hover); } .cx-filter-row.cx-on { background: var(--accent-soft); color: var(--accent-hi); } .cx-filter-row.cx-on .cx-filter-label { font-weight: var(--fw-semibold); } diff --git a/app/ui/composer/elements/input.js b/app/ui/composer/elements/input.js index 0cef4a0b..e08394a7 100644 --- a/app/ui/composer/elements/input.js +++ b/app/ui/composer/elements/input.js @@ -169,7 +169,10 @@ define("filterList", "md", ({ items = [], value, onChange, placeholder = "Search !q || `${i.label} ${i.hint || ""} ${i.keywords || ""}`.toLowerCase().includes(q)); list.replaceChildren(); if (!shown.length) { list.append(el("div", { cls: "cx-filter-empty", text: empty })); return; } + let before; for (const item of shown) { + if (before !== undefined && item.group !== before) list.append(el("div", { cls: "cx-filter-gap", attrs: { "aria-hidden": "true" } })); // a new group starts after a gap + before = item.group; // `tone` picks one of a FIXED set of chip colours -- never a raw // colour from data, which is the rule `el()`'s style refusal exists to // hold everywhere else too. Enforced, not just claimed: an unknown diff --git a/app/ui/features/settings/about.js b/app/ui/features/settings/about.js new file mode 100644 index 00000000..5c509e91 --- /dev/null +++ b/app/ui/features/settings/about.js @@ -0,0 +1,26 @@ +// About FunPack: which build this is, and the machine ComfyUI runs on (which on a rental is not this browser's). +import { composer as c } from "../../composer/composer.js"; + +const facts = (rows) => rows.filter(([, v]) => v).map(([k, v]) => c.settingsRow.default({ label: k, control: c.text.sm({ text: String(v) }) })); + +export const about = (api) => function mount() { + const body = c.region.stack({ gap: "sm", label: "About" }); + body.set([c.hint.default({ text: "Looking…" })]); + Promise.all([api.gitStatus().catch(() => ({})), api.system().catch(() => null)]).then(([git, sys]) => { + const major = String(git.version || "").split(".")[0]; + const mem = (sys && sys.memory) || {}, cpu = (sys && sys.cpu) || {}, disk = (sys && sys.disk) || {}, torch = (sys && sys.torch) || {}, gpu = ((sys && sys.gpus) || [])[0]; + body.set([ + c.header.md({ text: `FunPack${major ? ` ${major}` : ""}` }), + c.hint.default({ text: [git.codename && `“${git.codename}”`, "Cutting Room"].filter(Boolean).join(" · ") }), + ...facts([["Version", git.version], ["Commit", git.commit && String(git.commit).slice(0, 7)], ["Branch", git.branch]]), + ...(sys ? [c.label.section({ text: "Hardware" }), + ...facts([["Chip", cpu.name], ["Memory", mem.total_gb != null && `${mem.available_gb} GB free of ${mem.total_gb} GB`], + ["Graphics", gpu ? [gpu.name, gpu.vram_gb && `${gpu.vram_gb} GB`].filter(Boolean).join(" · ") : sys.mps ? "Apple GPU (MPS)" : "CPU only"], + ["Storage", disk.total_gb != null && `${disk.free_gb} GB available of ${disk.total_gb} GB`]]), + c.label.section({ text: "Software" }), + ...facts([["System", sys.os], ["ComfyUI", sys.comfyui], ["Python", sys.python], ["Torch", [torch.version, torch.cuda && `CUDA ${torch.cuda}`].filter(Boolean).join(" · ")], ["Host", sys.host]]), + c.hint.default({ text: "The machine ComfyUI runs on — not this browser." })] : [c.hint.default({ text: "Machine details are not available from this server." })]), + ]); + }); + return body; +}; diff --git a/app/ui/features/settings/appearance.js b/app/ui/features/settings/appearance.js new file mode 100644 index 00000000..b7db8912 --- /dev/null +++ b/app/ui/features/settings/appearance.js @@ -0,0 +1,8 @@ +// Appearance: dark, light, or follow the system. +import { composer as c } from "../../composer/composer.js"; + +export const appearance = (theme) => function mount() { + return c.settingsRow.default({ label: "Colour scheme", hint: "Light, dark, or follow the system.", + control: c.segmented.sm({ label: "Colour scheme", value: theme.get(), onChange: (v) => theme.apply(v), + options: [{ value: "dark", label: "Dark" }, { value: "light", label: "Light" }, { value: "auto", label: "Auto" }] }) }); +}; diff --git a/app/ui/features/settings/index.js b/app/ui/features/settings/index.js new file mode 100644 index 00000000..b7a912be --- /dev/null +++ b/app/ui/features/settings/index.js @@ -0,0 +1,33 @@ +// Settings sections that sit in v4's window: the real ones, and stand-ins (named, grouped, saying what is coming) for the rest. +import { composer as c } from "../../composer/composer.js"; +import { about } from "./about.js"; +import { appearance } from "./appearance.js"; + +const later = (what) => function mount() { + return c.emptyState.default({ icon: "◌", title: "Not built yet", hint: what }); +}; + +export default { + id: "settings-sections", + mount: "settings", + needs: ["api", "theme"], + setup({ host, app }) { + const add = (id, title, subtitle, icon, tone, mount, group = "", keywords = "") => host.add({ id, group, title, subtitle, icon, tone, mount, keywords: `${keywords} ${title}` }); + add("about", "About FunPack", "", "◎", "accent", about(app.api), "", "version commit branch cpu memory gpu disk python torch"); + add("appearance", "Appearance", "Light, dark, or follow the system.", "◐", "neutral", appearance(app.theme), "", "theme colour light dark auto"); + add("editor", "Editor", "How the editor behaves. The open project remembers these, so they follow it to another machine.", "☰", "neutral", + later("Upscale model, prompt autocomplete, shortcut ideas, i2v options."), "", "upscale autocomplete shortcuts ideas preferences"); + add("engine", "Engine", "Settings every installed module has volunteered, grouped by what they're for.", "⚡", "warn", + later("Continuity, guidance, conditioning, sampling and post settings of the installed modules."), "Generation", "engine module settings"); + add("models", "Models & Pipeline", "Loaders and nodes wired into the live pipeline.", "⬡", "accent", + later("Pick the model, VAE, CLIP and LoRA files; see the live pipeline."), "Generation", "models loaders unet vae clip lora"); + add("modules", "Modules", "Switch a module off for this project; see and re-enable ones that failed.", "☷", "neutral", + later("Per-module on/off for this project, and the ones that failed to load."), "Generation", "modules enable disable quarantine"); + add("refinement", "Refinement & Taste", "Learned-taste state: refinement keys and the Absolute global-taste store.", "✦", "danger", + later("Refinement keys: export, delete; the taste store."), "Learning", "refinement taste keys rating"); + add("system", "Updates & ComfyUI", "Server connection, FunPack code updates, pipeline health.", "⟳", "good", + later("Update, switch branch, restart ComfyUI, roll back."), "System", "update git branch restart rollback"); + add("customnodes", "Custom Nodes", "Install, update and remove ComfyUI node packs.", "⧉", "neutral", + later("Install, update and remove node packs."), "System", "custom nodes packs install"); + }, +}; diff --git a/app/ui/features/temp/index.js b/app/ui/features/temp/index.js index ae7885ac..c37426f7 100644 --- a/app/ui/features/temp/index.js +++ b/app/ui/features/temp/index.js @@ -4,7 +4,7 @@ export default { id: "temp", mount: "settings", setup: ({ host }) => host.add({ - id: "temp", title: "Temp files", subtitle: "Where a file went when it did not land in the bin.", + id: "temp", group: "System", title: "Temp Files", subtitle: "Where a file went when it did not land in the bin.", keywords: "temp files output directory", icon: "▥", mount, }), }; diff --git a/app/ui/modules.js b/app/ui/modules.js index 0a8461c2..3e4daa20 100644 --- a/app/ui/modules.js +++ b/app/ui/modules.js @@ -12,5 +12,6 @@ export default [ "./features/render/index.js", "./features/placeholders/index.js", "./features/generate/warn.js", + "./features/settings/index.js", "./features/temp/index.js", ].map((path) => new URL(path, import.meta.url).href); diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index 55a93e50..2cc08171 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -22,6 +22,8 @@ export const api = { pipeline: () => call("GET", "/api/pipeline"), editPipeline: (body) => call("POST", "/api/pipeline", body), modules: (traits) => call("GET", `/api/modules${traits ? `?traits=${encodeURIComponent(traits)}` : ""}`), + system: () => call("GET", "/api/system"), + gitStatus: () => call("GET", "/api/git/status?remote=0"), media: () => call("GET", "/api/media"), deleteMedia: (id) => call("DELETE", `/api/media/${encodeURIComponent(id)}`), /** Upload files as media, one request each so one refusal (too big, wrong type) does not sink the rest. diff --git a/app/ui/shell/settings.js b/app/ui/shell/settings.js index ccf2d333..d7ec778b 100644 --- a/app/ui/shell/settings.js +++ b/app/ui/shell/settings.js @@ -1,13 +1,14 @@ // The Settings window. It owns no section: features announce theirs by mounting at "settings" -// setup({ host }) { host.add({ id, title, subtitle, keywords, icon, tone, mount({ setFooter, close }) -> handle }) } +// setup({ host }) { host.add({ id, group?, title, subtitle, keywords, icon, tone, mount({ setFooter, close }) -> handle }) } // and the window lists whatever arrived. It is also the one place a "Settings" button is made. import { composer } from "../composer/composer.js"; import { offer } from "./mounts.js"; export function createSettings({ menubar }) { + const GROUPS = ["", "Generation", "Learning", "System"]; // the order v4 laid the window out in const sections = []; // childNodes: the feature loader checks a region's children to undo a feature that fails half-way - const host = { childNodes: [], add: (section) => sections.push(section) }; + const host = { childNodes: [], add: (section) => { sections.push(section); sections.sort((a, b) => GROUPS.indexOf(a.group || "") - GROUPS.indexOf(b.group || "")); } }; offer("settings", host); let current = null; @@ -29,7 +30,7 @@ export function createSettings({ menubar }) { } const nav = composer.filterList.md({ - items: sections.map((s) => ({ id: s.id, label: s.title, icon: s.icon, keywords: s.keywords, tone: s.tone || "neutral" })), + items: sections.map((s) => ({ id: s.id, label: s.title, icon: s.icon, keywords: s.keywords, tone: s.tone || "neutral", group: s.group || "" })), value: id, onChange: show, placeholder: "Search settings", }); nav.node.classList.add("cx-filter-full", "cx-settings-rail");