From faaa465d6841f53ffaebdf746438fb1174dc9779 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:30:59 +0300 Subject: [PATCH] app, core: the inspector, the wheel, ratings, a ruler, and a way in The surfaces v4 has that v5 did not. Each announces itself and each can be absent without the rest noticing, which is the point of the exercise: the frontend should read as v4, and nothing in it should be load-bearing for anything else. * SCENE AND PROJECT, in the column v4 keeps them in, which now says which scene it is about. Length on the timeline is the scene's own CROP and says outright that a regenerate reads the project's -- the rule that mixing the two is how a per-scene value quietly becomes everyone's. * RATINGS on a scene, and a clip carries its rating as a colour on its edge. Stored, not yet read: nothing in v5 learns from them yet. * A RULER, and clips as wide as they are long, so the strip reads as time rather than a row of equal boxes. Both ends bounded -- one long clip must not become the whole strip, and one short one must stay hittable. * THE PICKER WHEEL, on the middle button anywhere. It shows whatever announced itself to actions.js, so a part of the app that grew a new action is in the wheel without the wheel being told, and a part that failed to load is simply not among them. v4's three pinned buttons had a fixed idea of what could go in them. * THE WAY IN, and it closes -- on Escape, on the backdrop, on its own button. v4's could only be completed: a page refresh was the only way out, which makes it a trap rather than a wizard. Dismissing is a real answer. * File > rename, and the listing moves with it rather than going stale until a reload. Two things the kit refused, correctly, and now nobody has to know: a wheel of fewer than two items throws (there is nothing to aim between), so the shell does not offer one; and a strip cell with no ceiling took the whole strip. --- app/boot.js | 46 ++++++- app/catalogue/demos.js | 4 + app/composer/elements/gallery.css | 27 +++- app/composer/elements/gallery.js | 35 ++++- app/composer/elements/layout.js | 9 +- app/shell/actions.js | 42 ++++++ app/shell/inspector.js | 106 +++++++++++++++ app/shell/layout.js | 20 ++- app/shell/projects.js | 34 +++-- app/shell/tests/projects.test.js | 26 ++++ app/shell/tests/ui.test.js | 206 ++++++++++++++++++++++++++++++ app/shell/timeline.js | 38 +++++- app/shell/wheel.js | 57 +++++++++ app/shell/wizard.js | 45 +++++++ core/projects.py | 31 ++++- core/tests/test_projects.py | 22 ++++ 16 files changed, 719 insertions(+), 29 deletions(-) create mode 100644 app/shell/actions.js create mode 100644 app/shell/inspector.js create mode 100644 app/shell/tests/ui.test.js create mode 100644 app/shell/wheel.js create mode 100644 app/shell/wizard.js diff --git a/app/boot.js b/app/boot.js index 85eb0668..c184c33c 100644 --- a/app/boot.js +++ b/app/boot.js @@ -17,6 +17,10 @@ import { open as openPipeline } from "./shell/pipeline_window.js"; import { createPrompts } from "./shell/prompt.js"; import { createProject } from "./shell/projects.js"; import { createTimeline } from "./shell/timeline.js"; +import { createInspector } from "./shell/inspector.js"; +import { createWheel } from "./shell/wheel.js"; +import { offerAction } from "./shell/actions.js"; +import { open as openWizard } from "./shell/wizard.js"; const root = document.querySelector("#app"); @@ -41,8 +45,12 @@ async function start() { // read so a failure to reach the server leaves an empty timeline rather than // an undefined one. let timeline = null; + let inspector = null; const project = createProject({ - onChange: () => { if (timeline) timeline.draw(); }, + onChange: () => { + if (timeline) timeline.draw(); + if (inspector) inspector.draw(); + }, onError: (err) => page.transport.say(`The project could not be saved: ${err.message}`), }); @@ -60,6 +68,9 @@ async function start() { const showScene = (scene) => { const box = prompts && prompts.at("generation.prompt"); if (box) box.setValue(scene ? scene.text : ""); + // The properties column is about ONE scene, and says which. + const at = scene ? project.scenes.findIndex((s) => s.id === scene.id) + 1 : 0; + if (page && page.properties) page.properties.setTitle(at ? `Scene · ${at}` : "Scene"); }; // Which scene a run was started for, read at the moment it starts. A run @@ -67,7 +78,11 @@ async function start() { // end is not the one that was generated. let ranFor = null; + // Before the page, because the properties column is composed with it in. + inspector = createInspector({ project, onRename: (name) => project.rename(name) }); + const page = build(root, { + inspector, onGenerate: () => { ranFor = project.selectedId; session.generate(); }, onCancel: () => run.cancel(), onConstructor: () => page.constructor.open(), @@ -171,9 +186,33 @@ async function start() { settle(); showGroups(); + // What the app can be asked to do, offered by the part that owns each one. + // The wheel shows whatever is here; nothing has to tell it about a new one. + offerAction({ id: "generate", icon: "▶", label: "Generate", run: () => session.generate() }); + offerAction({ id: "cancel", icon: "■", label: "Cancel", run: () => run.cancel() }); + offerAction({ id: "constructor", icon: "✎", label: "Constructor", + run: () => page.constructor.open() }); + offerAction({ id: "scene", icon: "+", label: "Add scene", + run: () => { project.addScene(); showScene(project.selected); } }); + offerAction({ id: "pipeline", icon: "⚙", label: "Models", + run: () => page.menubar.settings.node.click() }); + offerAction({ id: "assets", icon: "▤", label: "Assets", + run: () => page.workspace.toggle("left") }); + const wheel = createWheel(); + // After the prompt exists, so the first scene's text has somewhere to go. try { - await project.start(); + const fresh = await project.start(); + // Nothing to come back to: offer the way in. Dismissing is a real answer -- + // the project is already made, and it carries on with one empty scene. + if (!(fresh.scenes || []).length) { + openWizard({ onPick: (choice) => { + const wanted = choice === "scenes" ? 3 : 1; + for (let i = 0; i < wanted; i += 1) project.addScene(); + showScene(project.selected); + } }); + if (!project.scenes.length) project.addScene(); + } // The project's own settings win over the pipeline's defaults: they are what // it was generated at, and a regenerate is a new scene at the same size -- // never at whatever the last one happened to be cropped to. @@ -182,6 +221,7 @@ async function start() { } timeline = createTimeline({ project, onSelect: showScene }); page.timelineBody.set([page.transport.warning, timeline]); + showScene(project.selected); } catch (err) { console.warn(`[FunPack] no project: ${err.message}`); @@ -200,7 +240,7 @@ async function start() { for (const { where, why } of failed) console.warn(`[FunPack] ${where} did not load: ${why}`); window.FunPack = { - manifest, values: allValues, failed, hidden, run, bin: page.bin, project, + manifest, values: allValues, failed, hidden, run, bin: page.bin, project, wheel, prompts: () => (prompts ? prompts.overrides() : {}), mounted: mounted.map((m) => m.id), }; diff --git a/app/catalogue/demos.js b/app/catalogue/demos.js index cf0159e9..3cf4d768 100644 --- a/app/catalogue/demos.js +++ b/app/catalogue/demos.js @@ -330,6 +330,10 @@ export const DEMOS = { { id: "1", icon: "▦", label: "Scene 1" }, { id: "2", icon: "▦", label: "Scene 2" }, { id: "3", icon: "▦", label: "Scene 3" }, { id: "4", icon: "▦", label: "Scene 4" }, ] }, + "ruler.default": { label: "Scenes", total: 240, marks: [ + { at: 0, label: "1", hint: "0" }, { at: 0.4, label: "2", hint: "96" }, + { at: 0.75, label: "3", hint: "180" }, + ] }, "wheel.half": { edge: "right", items: [ { icon: "✂", label: "Split" }, { icon: "⧉", label: "Duplicate" }, { icon: "★", label: "Rate" }, { icon: "◐", label: "Anchor" }, diff --git a/app/composer/elements/gallery.css b/app/composer/elements/gallery.css index 9f7bb6cc..c34d527d 100644 --- a/app/composer/elements/gallery.css +++ b/app/composer/elements/gallery.css @@ -91,7 +91,10 @@ min-inline-size: 0; } .cx-strip-cell { - flex: none; width: 72px; aspect-ratio: 16 / 10; padding: 0; + /* A floor and a ceiling. A clip grows with its length (flex-grow, set by the + element), and one clip in a project must not become the whole strip. */ + flex: none; width: 72px; min-inline-size: 72px; max-inline-size: var(--cell-panel); + aspect-ratio: 16 / 10; padding: 0; border: var(--bw) solid var(--line); border-radius: var(--r-2); background: var(--void); overflow: hidden; cursor: pointer; display: flex; align-items: center; justify-content: center; @@ -115,3 +118,25 @@ inline-size: auto; block-size: auto; object-fit: contain; } + +/* A clip carries its rating as a colour on its edge -- the one thing about a + result you want to see without selecting it. */ +.cx-strip-cell.cx-rated { border-color: var(--muted); } +.cx-strip-cell[data-rating="perfect"] { border-color: var(--good); } +.cx-strip-cell[data-rating="good"] { border-color: var(--teal); } +.cx-strip-cell[data-rating="wrong"] { border-color: var(--warn); } +.cx-strip-cell[data-rating="awful"] { border-color: var(--danger); } + +/* Where one scene becomes the next. Positioned in percent so it tracks the row + above it at any width, and never taller than the numbers it draws. */ +.cx-ruler { + position: relative; block-size: var(--lh-8); flex: none; + border-block-end: var(--bw) solid var(--line-2); +} +.cx-ruler-mark { + position: absolute; inset-block-start: 0; + padding-inline-start: var(--sp-1); + border-inline-start: var(--bw) solid var(--line); + font-size: var(--fs-8); line-height: var(--lh-8); color: var(--faint); + font-variant-numeric: tabular-nums; +} diff --git a/app/composer/elements/gallery.js b/app/composer/elements/gallery.js index 2b8de6a5..9f61c0bc 100644 --- a/app/composer/elements/gallery.js +++ b/app/composer/elements/gallery.js @@ -194,8 +194,14 @@ define("gallery", "strip", (props = {}) => { const node = el("div", { cls: "cx-strip", attrs: { role: "listbox", "aria-label": props.label } }); const api = collection(props, node, (item, on) => { - const cell = el("button", { cls: ["cx-strip-cell", on ? "cx-on" : null, "cx-focusable"], - attrs: option(on, { title: item.label, "aria-label": item.label }) }); + const cell = el("button", { + cls: ["cx-strip-cell", on ? "cx-on" : null, item.rating ? "cx-rated" : null, "cx-focusable"], + attrs: option(on, { title: item.label, "aria-label": item.label, + "data-rating": item.rating || undefined }) }); + // As wide as what it stands for is long, which is what turns a row of equal + // boxes into something that reads as time. Bounded at both ends: one very + // long clip beside short ones must not squeeze the rest to a sliver. + if (item.weight) cell.style.flexGrow = String(Math.min(6, Math.max(1, Number(item.weight) || 1))); // The same fallback the grid has: a thumbnail that does not arrive shows the // glyph, not the browser's broken-image icon, which reads as a damaged file. cell.append(thumbOf(item, "cx-strip-face", false)); @@ -212,6 +218,31 @@ define("gallery", "strip", (props = {}) => { }; }); +/** + * A scale over something laid out in a row: where each part begins. + * + * The marks are given rather than computed from an interval -- what a person + * looks for on a timeline is where one scene becomes the next, and at 24 frames + * a second a tick per second is a picket fence. + */ +define("ruler", "default", ({ marks = [], total = 1, label } = {}) => { + const node = el("div", { cls: "cx-ruler", attrs: { role: "presentation", "aria-label": label } }); + + function set(next = [], span = 1) { + node.replaceChildren(); + for (const mark of next) { + const tick = el("span", { cls: "cx-ruler-mark", text: mark.label, attrs: { title: mark.hint } }); + // Percent, so the ruler tracks the row above it at any width. + tick.style.insetInlineStart = `${Math.min(100, Math.max(0, (mark.at || 0) * 100))}%`; + node.append(tick); + } + node.dataset.total = String(span); + } + + set(marks, total); + return { node, set, destroy: () => node.remove() }; +}); + /** * The result, big. One image or one video, or an empty state before there is * either -- because "nothing has been generated" and "the picture failed to diff --git a/app/composer/elements/layout.js b/app/composer/elements/layout.js index ee0ccd4a..81be0222 100644 --- a/app/composer/elements/layout.js +++ b/app/composer/elements/layout.js @@ -6,6 +6,7 @@ import { define } from "../internals/register.js"; import { el } from "../internals/el.js"; +import { setText } from "../internals/text.js"; import { uid } from "../internals/ids.js"; import { drag } from "../internals/drag.js"; import { roving } from "../internals/focus.js"; @@ -91,6 +92,7 @@ const panel = (variant) => ({ title, actions = [], status = [], body, flush = fa status.length ? el("div", { cls: "cx-panel-status", children: status.map((a) => nodeOf(a, `panel.${variant} status`)) }) : null, ].filter(Boolean) }) : null; + const title_ = head && head.querySelector(".cx-panel-title"); const content = el("div", { cls: "cx-panel-body", children: body ? nodeOf(body, `panel.${variant}`) : null }); // `flush` is for a body that IS the content -- a picture, a video, a canvas. // Padding around one of those is a frame nobody asked for, and the thing @@ -98,7 +100,12 @@ const panel = (variant) => ({ title, actions = [], status = [], body, flush = fa const node = el("section", { cls: ["cx-panel", variant === "zone" ? "cx-zone" : null, flush ? "cx-panel-flush" : null], children: [head, content].filter(Boolean) }); - return { node, body: content, destroy: () => node.remove() }; + return { + node, body: content, + /** A zone that is ABOUT something says which: "Scene · 2", not "Scene". */ + setTitle(next) { if (title_) setText(title_, next); }, + destroy: () => node.remove(), + }; }; define("panel", "default", panel("default")); diff --git a/app/shell/actions.js b/app/shell/actions.js new file mode 100644 index 00000000..349e21f6 --- /dev/null +++ b/app/shell/actions.js @@ -0,0 +1,42 @@ +// Everything the app can be asked to do, by name. +// +// Announced, not listed: whoever builds a control offers it here, and whoever +// shows a list of them -- the wheel today, a command palette or a keymap later +// -- reads what is there. A part of the app that failed to load offers nothing +// and is simply not among them, which is the same rule regions and modules +// live by. + +const actions = new Map(); + +/** + * offerAction({ id, label, icon, run }) -> a function that takes it away again. + * + * The id is the address. Offering the same one twice replaces it, because the + * alternative is two entries that look identical and do different things. + */ +export function offerAction({ id, label, icon, run } = {}) { + if (!id || typeof run !== "function") { + throw new TypeError("An action needs an id and something to run."); + } + actions.set(id, { id, label: label || id, icon: icon || "•", run }); + return () => actions.delete(id); +} + +/** In the order they were offered, which is the order they were built in. */ +export const offered = () => [...actions.values()]; + +/** Run one by name. Unknown is not an error: whatever offered it may be gone. */ +export function run(id) { + const action = actions.get(id); + if (!action) return false; + try { + action.run(); + } catch (err) { + // One broken action must not take down the thing that listed it. + console.warn(`[FunPack] the "${id}" action failed: ${err.message}`); + return false; + } + return true; +} + +export function _reset() { actions.clear(); } diff --git a/app/shell/inspector.js b/app/shell/inspector.js new file mode 100644 index 00000000..17eed18b --- /dev/null +++ b/app/shell/inspector.js @@ -0,0 +1,106 @@ +// The inspector: what the PROJECT is, and what THIS SCENE is. +// +// Two tabs because they are two different things and v4 proved that mixing them +// is how a per-scene value quietly becomes the project's. The rule this file +// exists to make visible: a regenerate reads the PROJECT, and a crop is the +// scene's own -- so Length appears in both places and means something different +// in each, and both say so. + +import { composer } from "../composer/composer.js"; + +const RATINGS = [ + { value: "perfect", label: "Perfect" }, + { value: "good", label: "Good" }, + { value: "wrong", label: "Wrong" }, + { value: "awful", label: "Awful" }, +]; + +export function createInspector({ project, onRename } = {}) { + const host = composer.region.stack({ gap: "sm", fill: true }); + let tab = "scene"; + + const tabs = composer.tabs.underline({ + value: tab, label: "Inspector", + tabs: [{ value: "scene", label: "Scene" }, { value: "project", label: "Project" }], + onChange: (next) => { tab = next; draw(); }, + }); + + const rows = composer.region.stack({ gap: "sm", label: "Inspector rows" }); + host.set([tabs, rows]); + + function sceneRows() { + const scene = project.selected; + if (!scene) { + return [composer.emptyState.default({ + icon: "▭", title: "No scene", hint: "Add one on the timeline." })]; + } + const at = project.scenes.indexOf(scene) + 1; + return [ + composer.label.section({ text: `Scene ${at}` }), + composer.settingsRow.default({ + label: "Length on the timeline", + // The rule, said where it applies: this is a crop, and a regenerate + // does not read it. + hint: "Frames this clip plays for. Regenerating uses the project's length.", + control: composer.number.md({ + value: scene.length ?? project.video.length ?? 1, min: 1, label: "Length", + onChange: (v) => project.setScene(scene.id, "length", v), + }), + }), + composer.settingsRow.default({ + label: "Rating", + hint: "What you thought of it. Kept with the scene.", + control: composer.buttonGroup.md({ + label: "Rating", value: scene.rating || undefined, items: RATINGS, + onChange: (v) => project.setScene(scene.id, "rating", v), + }), + }), + composer.settingsRow.default({ + label: "Result", + hint: scene.result ? "This scene has been generated." : "Not generated yet.", + }), + ]; + } + + function projectRows() { + const open = project.project; + if (!open) return [composer.hint.default({ text: "No project is open." })]; + return [ + composer.label.section({ text: "Project" }), + composer.settingsRow.default({ + label: "Name", + control: composer.input.md({ + value: open.name, label: "Project name", + onCommit: (v) => { if (onRename) onRename(v); }, + }), + }), + composer.settingsRow.default({ + label: "Scenes", hint: `${project.scenes.length} on the timeline`, + }), + composer.hint.default({ + text: "Size and length live in the Constructor: they are what every scene " + + "is generated at.", + }), + ]; + } + + function draw() { + tabs.setValue(tab); + // Wrapped in a group, which is what draws the card AND is the container the + // stacking rule measures: settings rows outside one keep their 180px control + // column whatever the width, and a four-button group is then clipped. + const [head, ...body] = tab === "scene" ? sceneRows() : projectRows(); + rows.set([head, composer.group.default({ rows: body })]); + } + + draw(); + + return { + node: host.node, + draw, + /** Which tab is showing. Tests and the wheel both ask. */ + get tab() { return tab; }, + show(next) { tab = next; draw(); }, + destroy() { host.destroy(); }, + }; +} diff --git a/app/shell/layout.js b/app/shell/layout.js index 4f764ac7..625df9cc 100644 --- a/app/shell/layout.js +++ b/app/shell/layout.js @@ -150,13 +150,21 @@ export function build(root, handlers = {}) { // ONE zone, not two panels stacked. A region of the app is one area with one // head on it; two heads in a column means two things, and there is only one // thing here -- what the next run will do. - const properties = composer.panel.zone({ - title: "Generation", - body: composer.region.stack({ - gap: "sm", label: "Properties", - children: [generation, composer.collapsible.default({ label: "Settings", body: settings })], - }), + // The inspector goes ABOVE the module panels: what this scene and this project + // are is read more often than any switch, and a column is read downwards. + const propertyRows = composer.region.stack({ + gap: "sm", label: "Properties", + children: [ + // What this scene and this project ARE, above the switches: a column is + // read downwards, and this is the half that is read every time. + handlers.inspector, + generation, + composer.collapsible.default({ label: "Settings", body: settings }), + ].filter(Boolean), }); + // Titled after what it is ABOUT, and it follows the timeline -- v4's right + // column reads "Scene · 1" and so does this. boot renames it on selection. + const properties = composer.panel.zone({ title: "Scene", body: propertyRows }); const workspace = composer.workspace.docked({ id: "main", diff --git a/app/shell/projects.js b/app/shell/projects.js index 417ad236..ad07f5ee 100644 --- a/app/shell/projects.js +++ b/app/shell/projects.js @@ -136,6 +136,18 @@ export function createProject({ onChange, onError } = {}) { changed(); // selection is not saved: it is a view }, + /** What it is called. The listing in the File menu reads this too, so both + * move together rather than the menu going stale until a reload. */ + rename(name) { + const clean = String(name || "").trim(); + if (!project || !clean || project.name === clean) return; + project.name = clean; + const listed = recent.find((p) => p.id === project.id); + if (listed) listed.name = clean; + scheduleSave(); + changed(); + }, + /** What the whole project generates at, whichever scene is current. */ get video() { return (project && project.video) || {}; }, @@ -148,13 +160,7 @@ export function createProject({ onChange, onError } = {}) { }, /** The text of one scene. The prompt box on the main window edits this. */ - setText(id, text) { - const scene = sceneAt(id); - if (!scene || scene.text === text) return; - scene.text = text; - scheduleSave(); - changed(); - }, + setText(id, text) { this.setScene(id, "text", text); }, addScene() { if (!project) return null; @@ -162,7 +168,7 @@ export function createProject({ onChange, onError } = {}) { // through a timeline is how a scene gets inserted, and appending silently // would put it somewhere the user was not looking. const at = project.scenes.findIndex((s) => s.id === selected); - const scene = { id: newId(), text: "", result: null }; + const scene = { id: newId(), text: "", result: null, length: null, rating: null }; project.scenes.splice(at < 0 ? project.scenes.length : at + 1, 0, scene); selected = scene.id; // you add a scene in order to fill it scheduleSave(); @@ -193,15 +199,19 @@ export function createProject({ onChange, onError } = {}) { changed(); }, - /** Attach what a run produced to the scene it was started from. */ - setResult(id, result) { + /** One field of one scene. Everything a scene holds but its id goes through + * here, so there is one place that saves and one that says it changed. */ + setScene(id, key, value) { const scene = sceneAt(id); - if (!scene) return; - scene.result = result; + if (!scene || scene[key] === value) return; + scene[key] = value; scheduleSave(); changed(); }, + /** Attach what a run produced to the scene it was started from. */ + setResult(id, result) { this.setScene(id, "result", result); }, + flush, get unsaved() { return dirty; }, }; diff --git a/app/shell/tests/projects.test.js b/app/shell/tests/projects.test.js index 7eec90e3..3ff117ad 100644 --- a/app/shell/tests/projects.test.js +++ b/app/shell/tests/projects.test.js @@ -94,3 +94,29 @@ test("a run's result lands on the scene it was started for, not the current one" await p.flush(); assert.equal(sent.at(-1).scenes[0].result, "/view?filename=a.png"); }); + +test("renaming a project moves the name in the File menu too", async () => { + // The listing and the open project are the same name seen twice; a menu that + // kept the old one until a reload is a menu that lies about what is open. + const { sent } = server(); + const p = createProject({}); + await p.start(); + + p.rename("Rooftops"); + assert.equal(p.project.name, "Rooftops"); + assert.deepEqual(p.recent.map((r) => r.name), ["Rooftops"]); + + await p.flush(); + assert.equal(sent.at(-1).name, "Rooftops"); +}); + +test("a rename that says nothing changes nothing", async () => { + const { sent } = server(); + const p = createProject({}); + await p.start(); + + p.rename(" "); + p.rename("Untitled"); + await p.flush(); + assert.equal(sent.length, 0); +}); diff --git a/app/shell/tests/ui.test.js b/app/shell/tests/ui.test.js new file mode 100644 index 00000000..80fa2899 --- /dev/null +++ b/app/shell/tests/ui.test.js @@ -0,0 +1,206 @@ +// The parts added to make v5 an app rather than a generate button: the action +// registry and the wheel that shows it, the inspector, and the way in. +// +// One file, because each is small and they are all the same shape: something +// announces, something else draws whatever announced. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { setupDom, teardownDom, fire } from "../../composer/tests/_dom.js"; + +let actions, createWheel, createInspector, openWizard; +test.before(async () => { + setupDom(); + await import("../../composer/composer.js"); + actions = await import("../actions.js"); + ({ createWheel } = await import("../wheel.js")); + ({ createInspector } = await import("../inspector.js")); + ({ open: openWizard } = await import("../wizard.js")); +}); +test.after(() => teardownDom()); + +// --- actions ---------------------------------------------------------------- + +test("an action is offered by whoever owns it, and taken away with it", () => { + actions._reset(); + const ran = []; + const drop = actions.offerAction({ id: "go", label: "Go", run: () => ran.push("go") }); + + assert.deepEqual(actions.offered().map((a) => a.id), ["go"]); + assert.equal(actions.run("go"), true); + assert.deepEqual(ran, ["go"]); + + drop(); + assert.deepEqual(actions.offered(), []); + // Not an error: whatever offered it may simply be gone. + assert.equal(actions.run("go"), false); +}); + +test("one broken action does not take down the list that showed it", () => { + actions._reset(); + actions.offerAction({ id: "bad", run: () => { throw new Error("nope"); } }); + actions.offerAction({ id: "good", run: () => {} }); + + assert.equal(actions.run("bad"), false); + assert.equal(actions.run("good"), true, "a neighbour's failure stopped this one"); +}); + +test("offering the same id twice replaces it", () => { + // Two entries that look identical and do different things is worse than one. + actions._reset(); + const ran = []; + actions.offerAction({ id: "go", label: "First", run: () => ran.push(1) }); + actions.offerAction({ id: "go", label: "Second", run: () => ran.push(2) }); + + assert.equal(actions.offered().length, 1); + assert.equal(actions.offered()[0].label, "Second"); + actions.run("go"); + assert.deepEqual(ran, [2]); +}); + +// --- the wheel -------------------------------------------------------------- + +const middle = (node, init = {}) => node.dispatchEvent( + new window.MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true, ...init })); + +test("the middle button opens the wheel on whatever is offered", () => { + actions._reset(); + actions.offerAction({ id: "generate", label: "Generate", run: () => {} }); + actions.offerAction({ id: "cancel", label: "Cancel", run: () => {} }); + const wheel = createWheel(); + + middle(document.body); + assert.equal(wheel.isOpen, true); + assert.match(document.body.textContent, /Generate/); + wheel.close(); + assert.equal(wheel.isOpen, false); + wheel.destroy(); +}); + +test("a wheel of one is not a wheel", () => { + // Every action comes from a part of the app that loaded, so "only one" is a + // real state -- and there is nothing to aim between. The kit refuses it; no + // wheel is a better answer than an error thrown under the pointer. + actions._reset(); + const wheel = createWheel(); + middle(document.body); + assert.equal(wheel.isOpen, false, "a wheel opened with nothing in it"); + + actions.offerAction({ id: "a", run: () => {} }); + middle(document.body); + assert.equal(wheel.isOpen, false, "a wheel opened with one item in it"); + wheel.destroy(); +}); + +test("the middle button's own behaviour is prevented", () => { + // Otherwise Chrome starts autoscroll under the wheel. + actions._reset(); + actions.offerAction({ id: "a", run: () => {} }); + actions.offerAction({ id: "b", run: () => {} }); + const wheel = createWheel(); + const event = new window.MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true }); + document.body.dispatchEvent(event); + assert.equal(event.defaultPrevented, true); + wheel.destroy(); +}); + +test("a wheel that is already open is not opened again", () => { + actions._reset(); + actions.offerAction({ id: "a", run: () => {} }); + actions.offerAction({ id: "b", run: () => {} }); + const wheel = createWheel(); + middle(document.body); + const first = document.querySelectorAll(".cx-wheel").length; + middle(document.body); + assert.equal(document.querySelectorAll(".cx-wheel").length, first); + wheel.destroy(); +}); + +// --- the inspector ---------------------------------------------------------- + +function fakeProject(over = {}) { + const scene = { id: "aaaaaaaaaaaa", text: "", result: null, length: null, rating: null }; + const written = []; + return { + written, + project: { id: "p", name: "Untitled" }, + scenes: [scene], + selectedId: scene.id, + selected: scene, + video: { length: 97 }, + setScene: (id, key, value) => { written.push([id, key, value]); scene[key] = value; }, + rename: (name) => written.push(["project", "name", name]), + ...over, + }; +} + +test("the scene tab edits the scene, and says the crop is not what regenerates", () => { + const project = fakeProject(); + const inspector = createInspector({ project }); + document.body.replaceChildren(inspector.node); + + assert.match(inspector.node.textContent, /Regenerating uses the project's length/); + const length = inspector.node.querySelector('input[type="number"]'); + assert.equal(length.value, "97", "a scene with no crop of its own shows the project's length"); + + length.value = "48"; + fire(length, "blur"); + assert.deepEqual(project.written.at(-1), [project.selected.id, "length", 48]); +}); + +test("a rating is kept with the scene", () => { + const project = fakeProject(); + const inspector = createInspector({ project }); + document.body.replaceChildren(inspector.node); + + const good = [...inspector.node.querySelectorAll("button")].find((b) => b.textContent === "Good"); + fire(good, "click"); + assert.deepEqual(project.written.at(-1), [project.selected.id, "rating", "good"]); +}); + +test("the project tab renames the project", () => { + const project = fakeProject(); + const renamed = []; + const inspector = createInspector({ project, onRename: (n) => renamed.push(n) }); + document.body.replaceChildren(inspector.node); + + inspector.show("project"); + const name = inspector.node.querySelector('input[type="text"]'); + assert.equal(name.value, "Untitled"); + name.value = "Rooftops"; + fire(name, "blur"); + assert.deepEqual(renamed, ["Rooftops"]); +}); + +test("with no scene selected the inspector says so instead of drawing nothing", () => { + const inspector = createInspector({ project: fakeProject({ selected: null, scenes: [] }) }); + document.body.replaceChildren(inspector.node); + assert.match(inspector.node.textContent, /No scene/); +}); + +// --- the way in ------------------------------------------------------------- + +test("the wizard can be left, and leaving is an answer", () => { + // v4's could not be dismissed: a page refresh was the only way out, which + // makes it a trap rather than a wizard. + const picked = []; + const window_ = openWizard({ onPick: (id) => picked.push(id) }); + assert.ok(document.querySelector(".cx-modal")); + + window_.close("dismissed"); + assert.deepEqual(picked, [null], "dismissing said nothing at all"); + assert.equal(document.querySelector(".cx-modal"), null); +}); + +test("a choice is answered once, however it was closed", () => { + const picked = []; + const window_ = openWizard({ onPick: (id) => picked.push(id) }); + const card = [...document.querySelectorAll(".cx-card")] + .find((c) => /A few scenes/.test(c.textContent)); + fire(card, "click"); + + assert.deepEqual(picked, ["scenes"]); + window_.close("again"); + assert.deepEqual(picked, ["scenes"], "closing after a choice answered a second time"); +}); diff --git a/app/shell/timeline.js b/app/shell/timeline.js index f3279425..1db70647 100644 --- a/app/shell/timeline.js +++ b/app/shell/timeline.js @@ -63,6 +63,9 @@ export function createTimeline({ project, onSelect } = {}) { if (onSelect) onSelect(project.selected || null); } + /** Frames this clip runs for: its own crop, or the project's length. */ + const lengthOf = (scene) => scene.length || project.video.length || 1; + function items() { return project.scenes.map((scene, i) => ({ id: scene.id, @@ -73,9 +76,39 @@ export function createTimeline({ project, onSelect } = {}) { badge: String(i + 1), thumb: scene.result || null, icon: "▦", + // A clip as wide as it is long, so the strip reads as time rather than as + // a row of equal boxes. Bounded: one very long scene beside several short + // ones must not squeeze the rest to nothing. + weight: lengthOf(scene), + rating: scene.rating || null, })); } + /** Where each scene starts, in frames. The ruler is drawn from this. */ + function marks() { + let at = 0; + return project.scenes.map((scene) => { + const start = at; + at += lengthOf(scene); + return { id: scene.id, start, length: lengthOf(scene) }; + }); + } + + // The ruler. Its ticks are the scene boundaries rather than a fixed interval: + // what a person looks for on this timeline is where one scene becomes the + // next, and at 24fps a tick per second is a picket fence. + const ruler = composer.ruler.default({ label: "Scenes" }); + + function drawRuler() { + const spans = marks(); + const total = spans.reduce((sum, m) => sum + m.length, 0) || 1; + ruler.set(spans.map((m, i) => ({ + at: m.start / total, + label: `${i + 1}`, + hint: `${m.start}`, + })), total); + } + function draw() { const scenes = project.scenes; if (!scenes.length) { @@ -90,9 +123,11 @@ export function createTimeline({ project, onSelect } = {}) { selection: project.selectedId ? [project.selectedId] : [], onActivate: (item) => { project.select(item.id); announce(); }, }); - host.set([controls, strip]); + host.set([controls, ruler, strip]); + drawRuler(); return; } + drawRuler(); // setItems then setValue: setValue redraws against whatever items are // current, so seeding the selection first marks a row that is about to be // replaced and the strip comes back with nothing on. @@ -107,6 +142,7 @@ export function createTimeline({ project, onSelect } = {}) { draw, destroy() { if (strip) strip.destroy(); + ruler.destroy(); controls.destroy(); empty.destroy(); host.destroy(); diff --git a/app/shell/wheel.js b/app/shell/wheel.js new file mode 100644 index 00000000..9ba4c6d5 --- /dev/null +++ b/app/shell/wheel.js @@ -0,0 +1,57 @@ +// The picker wheel: everything the app can do, under the pointer. +// +// v4 had three pinned buttons in the toolbar and a fixed idea of what could go +// in them. This is the other way round -- it shows whatever announced itself, +// so a part of the app that grew a new action is in the wheel without the wheel +// being told. +// +// Middle mouse by default, anywhere. The middle button is otherwise autoscroll, +// which does nothing useful over a panel. + +import { composer } from "../composer/composer.js"; +import { offered, run } from "./actions.js"; + +const MIDDLE = 1; + +export function createWheel({ button = MIDDLE, root = document } = {}) { + let open = null; + + const close = () => { if (open) { open.close("dismissed"); open = null; } }; + + function show(x, y) { + const items = offered(); + // The kit refuses a wheel of one -- there is nothing to aim between -- and a + // page where only one part of the app loaded is exactly when that happens. + // No wheel is a better answer than a thrown error under the pointer. + if (items.length < 2 || open) return null; + open = composer.wheel.picker({ + x, y, items, + onPick: (item) => { open = null; run(item.id); }, + onClose: () => { open = null; }, + }); + return open; + } + + const down = (event) => { + if (event.button !== button) return; + // Autoscroll otherwise, which is nothing anyone wants over a panel. + event.preventDefault(); + show(event.clientX, event.clientY); + }; + // Chrome opens autoscroll on mousedown and only cancels it if the AUXCLICK is + // also prevented. + const aux = (event) => { if (event.button === button) event.preventDefault(); }; + + root.addEventListener("mousedown", down); + root.addEventListener("auxclick", aux); + + return { + show, close, + get isOpen() { return Boolean(open); }, + destroy() { + close(); + root.removeEventListener("mousedown", down); + root.removeEventListener("auxclick", aux); + }, + }; +} diff --git a/app/shell/wizard.js b/app/shell/wizard.js new file mode 100644 index 00000000..15720e06 --- /dev/null +++ b/app/shell/wizard.js @@ -0,0 +1,45 @@ +// The way in, on a first visit. +// +// v4's could not be dismissed: a page refresh was the only way out, which makes +// it a trap rather than a wizard. This one closes on Escape, on the backdrop, on +// its own close button and on "Start empty" -- and it only ever appears when +// there is nothing to come back to, so the way to never see it again is to have +// a project, which is what it makes. + +import { composer } from "../composer/composer.js"; + +const CHOICES = [ + { id: "empty", icon: "▭", label: "Empty project", + hint: "One scene, nothing in it." }, + { id: "scenes", icon: "▦", label: "A few scenes", + hint: "Three to write into, in order." }, +]; + +/** + * open({ onPick }) -> the modal handle. + * + * `onPick` is given the choice id, or null when the wizard was dismissed -- + * dismissing is a real answer, and the app carries on with what it has. + */ +export function open({ onPick } = {}) { + let answered = false; + const say = (id) => { + if (answered) return; + answered = true; + if (onPick) onPick(id); + }; + + const window_ = composer.modal.generic({ + title: "Welcome to FunPack", + subtitle: "Multi-scene video on a real timeline.", + size: "md", + body: composer.gallery.cards({ + items: CHOICES, + onActivate: (item) => { say(item.id); window_.close("picked"); }, + }), + actions: [composer.button.md({ label: "Start empty", tone: "ghost", + onClick: () => window_.close("dismissed") })], + onClose: () => say(null), + }); + return window_; +} diff --git a/core/projects.py b/core/projects.py index b679ba10..97cb227b 100644 --- a/core/projects.py +++ b/core/projects.py @@ -44,6 +44,26 @@ def _clean_name(raw, fallback="Untitled") -> str: return name[:MAX_NAME] or fallback +MAX_SETTING = 16384 + + +def _whole(value) -> int | None: + """A whole positive number, or nothing. Everything read back out of a + project file goes through here: the file outlives the code that wrote it.""" + if isinstance(value, bool) or value is None: + return None + try: + number = int(value) + except (TypeError, ValueError): + return None + return number if 1 <= number <= MAX_SETTING else None + + +#: What a person can say about a result. Core keeps the word and nothing else: +#: what any of them MEAN belongs to whatever learns from them, which is not here. +RATINGS = ("perfect", "good", "wrong", "awful") + + @dataclass class Scene: id: str = field(default_factory=_new_id) @@ -51,22 +71,27 @@ class Scene: #: The asset this scene last produced, so a reload shows the timeline the #: user left rather than an empty one they have to regenerate. result: str | None = None + #: How long this clip RUNS on the timeline -- a crop, made here. It is not + #: what a regenerate uses: that reads the project, because the crop was a + #: timeline decision and a regenerate is a new scene. + length: int | None = None + rating: str | None = None @staticmethod def from_dict(d) -> "Scene": d = d if isinstance(d, dict) else {} sid = d.get("id") result = d.get("result") + rating = d.get("rating") return Scene( id=sid if is_id(sid) else _new_id(), text=d.get("text") if isinstance(d.get("text"), str) else "", result=result if isinstance(result, str) else None, + length=_whole(d.get("length")), + rating=rating if rating in RATINGS else None, ) -MAX_SETTING = 16384 - - def _clean_video(raw) -> dict: """What the app is holding for the pipeline's `project.video` inputs. diff --git a/core/tests/test_projects.py b/core/tests/test_projects.py index 8e75d57a..48a429a2 100644 --- a/core/tests/test_projects.py +++ b/core/tests/test_projects.py @@ -265,3 +265,25 @@ def test_video_is_an_object_or_it_is_nothing(): def test_a_project_with_no_settings_has_an_empty_one(store): assert projects.Project.from_dict({}).video == {} assert projects.create("Fresh").video == {} + + +def test_a_scene_keeps_a_crop_and_a_rating(store): + made = projects.create("Rated") + made.scenes = [projects.Scene(text="a cat", length=48, rating="good")] + projects.save(made) + + back = projects.get(made.id).scenes[0] + assert back.length == 48 + assert back.rating == "good" + + +def test_a_scene_field_read_back_is_never_trusted(): + scene = projects.Scene.from_dict({ + "length": "48", # a number that arrived as text + "rating": "excellent", # not a word this app knows + }) + assert scene.length == 48 + assert scene.rating is None + + for junk in (0, -4, True, None, "soon", 999_999): + assert projects.Scene.from_dict({"length": junk}).length is None, junk