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