diff --git a/CLAUDE.md b/CLAUDE.md index f269ec69..fa3d164b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,7 +188,7 @@ Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/na - Plain values that change outside Svelte are made reactive with a `revision` counter bumped by subscriptions, read with `void revision` in `$derived.by`. - Lint wants `SvelteSet`/`SvelteMap` for mutable collections. For a throwaway copy, disable `svelte/prefer-svelte-reactivity` on that line. - **Docks (IntelliJ tool windows):** six slots (`bottom` is the bottom row's left part, `bottomRight` its right part). Their panels are icons in the left and right stripes (`ToolStripe.svelte`), and a docked stack shows only the active panel's header. Clicking the icon of a shown panel **hides its slot**: in e2e, use `showPanel`/`panelTab` (`e2e/helpers.ts`), never a bare click on a panel's tab. Floats keep a tab strip. A dock's icon shows before its plugin activates: built-in widget icons live in the kit (`icons.ts`). -- **Kit z-order:** dock 10 < float 100 < dialog 2000 < menu 2500 < toast 3000. Menus, selects and tooltips must open above dialogs. +- **Kit z-order:** dock 10 < float 100 < tour 1500 < dialog 2000 < menu 2500 < toast 3000. Menus, selects and tooltips must open above dialogs. - **Menus:** checked menu entries are plain `menuitem`s with a check icon (no `menuitemcheckbox` role). - **Actions and shortcuts:** - An _action_ is a command with its arguments (`actionId`). The palette and the keymap page list `listActions`: titled commands, plus menu entries that give arguments. diff --git a/apps/editor/.prettierignore b/apps/editor/.prettierignore index a0bae11d..a952bec5 100644 --- a/apps/editor/.prettierignore +++ b/apps/editor/.prettierignore @@ -19,3 +19,6 @@ test-results/ e2e/.workspace*/ .nanoforge/ .data/ + +# The tutorial project's files, written into a game as nf new writes its own. +tutorial/ diff --git a/apps/editor/e2e/helpers.ts b/apps/editor/e2e/helpers.ts index 09571b6f..b9f53c0f 100644 --- a/apps/editor/e2e/helpers.ts +++ b/apps/editor/e2e/helpers.ts @@ -54,3 +54,42 @@ export const openProject = async (page: Page, path: string): Promise => { await page.goto(`/load?path=${encodeURIComponent(path)}`); await page.getByRole('button', { name: 'Trust and open', exact: true }).click(); }; + +/** The settings RPC of the editor server, as the page's own session (account settings). */ +const settingsRpc = async (page: Page, method: string, input: unknown) => { + const response = await page.request.post(`/rpc/settings.${method}`, { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify(input), + }); + const body = (await response.json()) as { + result?: { revision: string | null; values: Record }; + }; + return { ok: response.ok(), result: body.result }; +}; + +/** An account setting as the editor server keeps it (`undefined`: not set). */ +export const accountSetting = async (page: Page, key: string): Promise => + (await settingsRpc(page, 'accountGet', null)).result?.values[key]; + +/** + * Puts an account setting back (`undefined` removes it). Account settings outlive tests (and the + * CI retry): a test that changes one reads it first, then puts it back. Written on the server + * directly, so nothing waits for the page's debounced push. + */ +export const putBackAccountSetting = async ( + page: Page, + key: string, + value: unknown, +): Promise => { + for (let attempt = 0; attempt < 5; attempt++) { + const document = (await settingsRpc(page, 'accountGet', null)).result; + if (!document) break; + const values = Object.fromEntries( + Object.entries(document.values).filter(([name]) => name !== key), + ); + if (value !== undefined) values[key] = value; + if ((await settingsRpc(page, 'accountPut', { baseRevision: document.revision, values })).ok) + return; + } + throw new Error(`Could not put back the account setting ${key}`); +}; diff --git a/apps/editor/e2e/prepare-workspace.mjs b/apps/editor/e2e/prepare-workspace.mjs index 4a1f4959..a97da526 100644 --- a/apps/editor/e2e/prepare-workspace.mjs +++ b/apps/editor/e2e/prepare-workspace.mjs @@ -1,3 +1,5 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { cpSync, existsSync, @@ -147,6 +149,23 @@ const copyGame = (source, destination) => { } }; +/** + * The panels' first-time tips take room the tests did not plan for: the local account starts + * with them put away, but for History's (`tips.spec.ts`). + */ +const tips = readdirSync(join(import.meta.dirname, '../../../plugins')) + .map((plugin) => join(import.meta.dirname, '../../../plugins', plugin, 'nanoforge.manifest.json')) + .filter((file) => existsSync(file)) + .flatMap((file) => JSON.parse(readFileSync(file, 'utf8')).contributes?.widgets ?? []) + .filter((widget) => widget.tip && widget.id !== 'history-panel.history' && !process.env.E2E_TIPS) + .map((widget) => widget.id); +const account = createHash('sha256').update('local').digest('hex').slice(0, 16); +mkdirSync(join(root, '.data/settings/accounts'), { recursive: true }); +writeFileSync( + join(root, `.data/settings/accounts/${account}.json`), + `${JSON.stringify({ revision: '1', values: { 'tips.dismissed': tips } }, null, 2)}\n`, +); + const engine = engineRoot(join(import.meta.dirname, '../../..')); const pong = join(engine, 'example/pong-network'); /** Engines without the editor bridge cannot be paused, stepped or stopped. */ @@ -194,6 +213,36 @@ if (existsSync(join(pong, 'node_modules/.bin/nf')) && bridge) { '', ].join('\n'), ); + if (cli && existsSync(join(cli, 'dist/nf.js'))) { + /** The tutorial's project, as `projects.createTutorial` makes it, on the engine's libraries. */ + const tutorial = join(root, 'my-first-game'); + execFileSync( + 'node', + [ + join(cli, 'dist/nf.js'), + 'new', + ...['--name', 'my-first-game', '--path', 'my-first-game', '--language', 'ts'], + ...['--package-manager', 'npm', '--no-strict', '--no-server', '--no-git', '--no-docker'], + '--skip-install', + ], + { cwd: root, stdio: 'ignore' }, + ); + cpSync(join(import.meta.dirname, '../tutorial'), tutorial, { recursive: true, force: true }); + writeFileSync( + join(tutorial, '.gitignore'), + `${readFileSync(join(tutorial, '.gitignore'), 'utf8')}\ndist/\n`, + ); + linkNodeModules(join(pong, 'apps/client/node_modules'), join(tutorial, 'node_modules')); + rmSync(join(tutorial, 'node_modules/@nanoforge-dev/cli'), { recursive: true, force: true }); + symlinkSync(cli, join(tutorial, 'node_modules/@nanoforge-dev/cli')); + symlinkSync( + realpathSync(join(pong, 'node_modules/@nanoforge-dev/config')), + join(tutorial, 'node_modules/@nanoforge-dev/config'), + ); + linkEditorLibrary(engine, tutorial); + } else { + console.warn('No built CLI in NANOFORGE_CLI: the tutorial e2e test is skipped.'); + } if (existsSync(join(engine, 'modules/scene/dist/index.js'))) { const breakout = join(root, 'breakout'); cpSync(join(import.meta.dirname, '../../../examples/breakout'), breakout, { diff --git a/apps/editor/e2e/test.ts b/apps/editor/e2e/test.ts index 6855b0d1..d0d26ae8 100644 --- a/apps/editor/e2e/test.ts +++ b/apps/editor/e2e/test.ts @@ -18,8 +18,10 @@ export const test = base.extend<{ pageErrors: string[] }>({ console.log(`[page ${message.type()}] ${message.text()}`), ); context.on('weberror', (webError) => { - const { message } = webError.error(); - if (!IGNORED.has(message)) errors.push(message); + const { message, stack } = webError.error(); + if (IGNORED.has(message)) return; + /** `E2E_STACKS=1`: where it was thrown, for errors that only show in a full run. */ + errors.push(process.env.E2E_STACKS && stack ? `${message}\n${stack}` : message); }); await use(errors); base.expect(errors, 'uncaught errors in the page').toEqual([]); diff --git a/apps/editor/e2e/tips.spec.ts b/apps/editor/e2e/tips.spec.ts new file mode 100644 index 00000000..5c85fa1e --- /dev/null +++ b/apps/editor/e2e/tips.spec.ts @@ -0,0 +1,40 @@ +import { + accountSetting, + openProject, + putBackAccountSetting, + resetLayout, + showPanel, +} from './helpers'; +import { expect, test } from './test'; + +/** The tips put away are an account setting, which outlives tests (and the CI retry). */ +let dismissed: unknown; +test.beforeEach(async ({ page }) => { + dismissed = await accountSetting(page, 'tips.dismissed'); +}); +test.afterEach(async ({ page }) => { + await putBackAccountSetting(page, 'tips.dismissed', dismissed); +}); + +test('a panel tip shows until put away, and stays away', async ({ page }) => { + await openProject(page, 'pong'); + await page.waitForURL(/\/project\//); + await resetLayout(page); + await showPanel(page, 'History'); + const panel = page.getByRole('region', { name: 'History', exact: true }); + const tip = panel.getByRole('complementary', { name: 'Tip' }); + await expect(tip).toContainText('The steps of what you are working on.'); + await expect(tip.getByRole('link', { name: 'Learn more' })).toHaveAttribute( + 'href', + 'https://docs.nanoforge.eu/docs/editor/overview', + ); + await tip.getByRole('button', { name: 'Got it' }).click(); + await expect(tip).toHaveCount(0); + /** Account settings are pushed after 500 ms. */ + await page.waitForTimeout(900); + await page.reload(); + await showPanel(page, 'History'); + await expect(page.getByRole('region', { name: 'History', exact: true })).toBeVisible(); + await expect(tip).toHaveCount(0); + await resetLayout(page); +}); diff --git a/apps/editor/e2e/tutorial.spec.ts b/apps/editor/e2e/tutorial.spec.ts new file mode 100644 index 00000000..8cbcebf2 --- /dev/null +++ b/apps/editor/e2e/tutorial.spec.ts @@ -0,0 +1,137 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { accountSetting, openProject, putBackAccountSetting } from './helpers'; +import { type Page, expect, test } from './test'; +import { TUTORIAL } from './workspace'; + +const MAIN = join(TUTORIAL, 'src/main.ts'); +const SYSTEM = join(TUTORIAL, 'src/systems/move-2d.system.ts'); +const sources = existsSync(MAIN) + ? { [MAIN]: readFileSync(MAIN, 'utf8'), [SYSTEM]: readFileSync(SYSTEM, 'utf8') } + : {}; + +const bubble = (page: Page) => + page.getByRole('dialog').filter({ has: page.locator('#nf-tour-title') }); +const step = (page: Page, title: string) => + expect(page.locator('#nf-tour-title')).toHaveText(title, { timeout: 30_000 }); +const press = (page: Page, name: string) => + bubble(page).getByRole('button', { name, exact: true }).click(); +const run = (page: Page, name: string) => + page.getByRole('toolbar', { name: 'Run' }).getByRole('button', { name, exact: false }); + +test.skip( + !existsSync(join(TUTORIAL, 'package.json')), + 'needs a built CLI (NANOFORGE_CLI) and engine (NANOFORGE_ENGINE)', +); + +/** Starting the tutorial puts the welcome away, in the account settings that outlive tests. */ +let welcome: unknown; +test.beforeEach(async ({ page }) => { + welcome = await accountSetting(page, 'welcome.show'); +}); + +test.afterEach(async ({ page }) => { + await putBackAccountSetting(page, 'welcome.show', welcome); + const stop = run(page, 'Stop'); + if (await stop.isVisible().catch(() => false)) { + await stop.click(); + await expect(run(page, 'Play')).toBeVisible({ timeout: 15_000 }); + } + for (const [path, text] of Object.entries(sources)) writeFileSync(path, text); + for (const folder of ['.git', 'dist']) + rmSync(join(TUTORIAL, folder), { recursive: true, force: true }); +}); + +test('the tutorial: from the welcome to a first commit', async ({ page }) => { + test.setTimeout(240_000); + /** A folder the tutorial did not make here is not trusted: open it once, as its user would. */ + await openProject(page, 'my-first-game'); + await page.waitForURL(/\/project\//); + await page.goto('/'); + await page + .getByRole('region', { name: 'Welcome to NanoForge' }) + .getByRole('button', { name: 'Start the tutorial' }) + .click(); + await page.waitForURL(/\/project\/[^?]+$/); + + await step(page, 'Your first game'); + await press(page, 'Next'); + await step(page, 'Screens'); + await press(page, 'Next'); + await step(page, 'The Scene screen'); + await page.getByRole('tab', { name: 'Scene', exact: true }).click(); + + await step(page, 'Add an entity'); + await press(page, 'Show Hierarchy'); + await page.getByRole('button', { name: 'Add entity' }).click(); + await step(page, 'Select the ball'); + await expect + .poll(() => readFileSync(MAIN, 'utf8')) + .toContain('const entity = registry.spawnEntity();'); + await page + .getByRole('tree', { name: 'Entities' }) + .getByRole('treeitem', { name: /^ball\b/ }) + .click(); + await press(page, 'Next'); + + await step(page, 'Change a value'); + const speed = page + .getByRole('region', { name: 'Velocity2D' }) + .getByRole('textbox', { name: 'x', exact: true }); + await speed.fill('6'); + await speed.press('Enter'); + + await step(page, 'Fix the mistake'); + await press(page, 'Show problems'); + await expect(page.getByText("Property 'speed' does not exist")).toBeVisible(); + await expect( + page.getByRole('button').and(page.getByTitle('Show problems', { exact: true })), + 'Problems should hold only the planted mistake: another error comes from the environment (a failed build, missing dependencies), and the step would never go on', + ).toHaveText(/^\s*1 error(?!s)/); + writeFileSync(SYSTEM, readFileSync(SYSTEM, 'utf8').replace('velocity.speed', 'velocity.x')); + + await step(page, 'Play'); + await run(page, 'Play').click(); + await step(page, 'Look inside the running game'); + await press(page, 'Show World'); + await expect(page.getByRole('region', { name: 'World', exact: true })).toBeVisible(); + await run(page, 'Stop').click(); + await expect(run(page, 'Play')).toBeVisible({ timeout: 15_000 }); + await press(page, 'Next'); + + await step(page, 'Keep your work in git'); + await press(page, 'Initialize repository'); + await step(page, 'Commit'); + /** The editor made this repository: a runner without a git identity could not commit in it. */ + execFileSync('git', ['config', 'user.name', 'E2E'], { cwd: TUTORIAL }); + execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: TUTORIAL }); + await press(page, 'Show Commit'); + const commit = page.getByRole('region', { name: 'Commit', exact: true }); + await commit + .getByRole('region', { name: 'Unversioned files', exact: true }) + .getByRole('checkbox', { name: 'Include all: Unversioned files', includeHidden: true }) + .check(); + await commit.getByRole('textbox', { name: 'Commit message' }).fill('my first game'); + await commit.getByRole('button', { name: 'Commit', exact: true }).click(); + + await step(page, 'Done'); + await press(page, 'Done'); + await expect(bubble(page)).toHaveCount(0); + expect(await page.evaluate(() => document.activeElement?.isConnected ?? true)).toBe(true); + expect(existsSync(join(TUTORIAL, '.git'))).toBe(true); +}); + +test('Help › Tutorial replays the tour, and Escape ends it', async ({ page }) => { + await openProject(page, 'my-first-game'); + await page.waitForURL(/\/project\//); + await page.getByRole('button', { name: 'Help', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Tutorial' }).click(); + await step(page, 'Your first game'); + await expect(bubble(page).getByText(/^Tutorial · 1 of \d+$/)).toBeVisible(); + await bubble(page).getByRole('button', { name: 'Next', exact: true }).focus(); + await page.keyboard.press('Escape'); + await expect(bubble(page)).toHaveCount(0); + expect(await page.evaluate(() => document.activeElement?.isConnected ?? true)).toBe(true); +}); diff --git a/apps/editor/e2e/workspace.ts b/apps/editor/e2e/workspace.ts index 8342a144..e555c1f4 100644 --- a/apps/editor/e2e/workspace.ts +++ b/apps/editor/e2e/workspace.ts @@ -9,5 +9,7 @@ export const PONG = join(WORKSPACE, 'pong'); export const GAME = join(WORKSPACE, 'game'); /** The breakout example (scenes), when the engine has the scene module. */ export const BREAKOUT = join(WORKSPACE, 'breakout'); +/** The tutorial's project (`my-first-game`), when `NANOFORGE_CLI` has a built CLI. */ +export const TUTORIAL = join(WORKSPACE, 'my-first-game'); /** The folder the editor server uses as the registry of plugins and packages. */ export const REGISTRY = join(WORKSPACE, '.registry'); diff --git a/apps/editor/scripts/build-server.ts b/apps/editor/scripts/build-server.ts index fdb7baa8..7a21cbba 100644 --- a/apps/editor/scripts/build-server.ts +++ b/apps/editor/scripts/build-server.ts @@ -41,4 +41,5 @@ if (existsSync(pluginsRoot)) { cpSync(built, join(dist, 'plugins', name), { recursive: true }); } } +cpSync(join(root, 'tutorial'), join(dist, 'tutorial'), { recursive: true }); console.log(`Editor server built in ${dist}`); diff --git a/apps/editor/server/main.ts b/apps/editor/server/main.ts index d45ba57b..4c5b12ce 100644 --- a/apps/editor/server/main.ts +++ b/apps/editor/server/main.ts @@ -2,6 +2,7 @@ * Entry of the editor server (`nf editor` runs `bun dist/index.js`): serves the RPC endpoint, * the WebSocket, plugin files and the built UI. */ +import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { type SocketData, createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core'; @@ -14,6 +15,12 @@ const editor = createEditorServer({ env: { ...env, bundledPluginsDir: env.bundledPluginsDir ?? join(import.meta.dir, 'plugins'), + /** Next to the built server, or the sources' `tutorial/` when run from them (dev). */ + tutorialDir: + env.tutorialDir ?? + [join(import.meta.dir, 'tutorial'), join(import.meta.dir, '../tutorial')].find((dir) => + existsSync(dir), + ), }, version: pkg.version, ...(process.env.EDITOR_NO_STATIC ? {} : { staticDir }), diff --git a/apps/editor/src/lib/editor/create-editor.ts b/apps/editor/src/lib/editor/create-editor.ts index c2922a07..21537e28 100644 --- a/apps/editor/src/lib/editor/create-editor.ts +++ b/apps/editor/src/lib/editor/create-editor.ts @@ -41,6 +41,7 @@ import { type Shell, StyleServiceToken, ThemeServiceToken, + TourServiceToken, setupShell, } from '@nanoforge-dev/editor-ui'; @@ -190,6 +191,9 @@ export const createEditor = async ( services.provide(KeybindingServiceToken, shell.keybindings); services.provide(StyleServiceToken, shell.styles); services.provide(ThemeServiceToken, shell.themes); + services.provide(TourServiceToken, shell.tours); + /** `editor.local`: games, git and installs work here (tour steps, menus). */ + services.get(CoreServices.ContextKeys).set('editor.local', session.mode === 'OFFLINE'); store.add(registerCorePanels(services.get(CoreServices.Extensions))); const code = store.add( followProjectCode({ diff --git a/apps/editor/src/lib/editor/register-app-contributions.ts b/apps/editor/src/lib/editor/register-app-contributions.ts index c2e110bf..f0adad46 100644 --- a/apps/editor/src/lib/editor/register-app-contributions.ts +++ b/apps/editor/src/lib/editor/register-app-contributions.ts @@ -8,11 +8,21 @@ import { } from '@nanoforge-dev/editor-kernel'; import { ProjectServiceToken } from '@nanoforge-dev/editor-project'; import type { SessionInfo } from '@nanoforge-dev/editor-protocol'; -import { MENU_ITEMS, MenuItemSchema, STATUS_BAR_ITEMS } from '@nanoforge-dev/editor-ui'; +import { + DOCS_URL, + MENU_ITEMS, + MenuItemSchema, + NotificationServiceToken, + STATUS_BAR_ITEMS, + TOURS, + TourSchema, + notifyError, +} from '@nanoforge-dev/editor-ui'; import { goto } from '$app/navigation'; -const DOCS_URL = 'https://docs.nanoforge.eu'; +import { startTutorial } from '../tutorial/start-tutorial'; +import { TUTORIAL_TOUR } from '../tutorial/tutorial-tour.const'; /** Commands, menus and status bar items of the editor application itself. */ export const registerAppContributions = (services: Container, session: SessionInfo): Disposable => { @@ -48,10 +58,28 @@ export const registerAppContributions = (services: Container, session: SessionIn handler: () => void window.open(DOCS_URL, '_blank', 'noopener'), }), ); + store.add( + commands.register({ + id: 'help.tutorial', + title: 'Tutorial', + category: 'Help', + handler: async () => { + const notifications = services.tryGet(NotificationServiceToken); + try { + if (!(await startTutorial(services, session))) + notifications?.notify('info', 'Open a project to take the tour'); + } catch (error) { + notifyError(notifications, 'Could not start the tutorial', error); + } + }, + }), + ); + store.add(extensions.contribute(TOURS, TourSchema.parse(TUTORIAL_TOUR), { owner: 'core' })); for (const item of [ { menu: 'file', command: 'project.close', group: 'project' }, { menu: 'view', command: 'workbench.focusNextPart', group: 'focus' }, - { menu: 'help', command: 'help.docs' }, + { menu: 'help', command: 'help.tutorial', order: 0 }, + { menu: 'help', command: 'help.docs', order: 1 }, ]) { store.add(extensions.contribute(MENU_ITEMS, MenuItemSchema.parse(item), { owner: 'core' })); } diff --git a/apps/editor/src/lib/tutorial/Welcome.svelte b/apps/editor/src/lib/tutorial/Welcome.svelte new file mode 100644 index 00000000..c008ea2b --- /dev/null +++ b/apps/editor/src/lib/tutorial/Welcome.svelte @@ -0,0 +1,69 @@ + + +{#if $shown} +
+

Welcome to NanoForge

+ {#if local} +

+ New here? The tutorial makes a small game, my-first-game, and walks you through it: add an + entity, change a value, fix a mistake, play it and save it in git. About ten minutes. +

+
+ + +
+ {:else} +

New here? Open one of your projects, then Help › Tutorial takes you around the editor.

+
+ +
+ {/if} +

Help › Tutorial plays it again at any time.

+
+{/if} + + diff --git a/apps/editor/src/lib/tutorial/start-tutorial.ts b/apps/editor/src/lib/tutorial/start-tutorial.ts new file mode 100644 index 00000000..5741a1ec --- /dev/null +++ b/apps/editor/src/lib/tutorial/start-tutorial.ts @@ -0,0 +1,24 @@ +import type { Container } from '@nanoforge-dev/editor-kernel'; +import { ProjectServiceToken } from '@nanoforge-dev/editor-project'; +import { ProjectsContract, type SessionInfo } from '@nanoforge-dev/editor-protocol'; +import { RpcClientToken } from '@nanoforge-dev/editor-rpc'; +import { TourServiceToken } from '@nanoforge-dev/editor-ui'; + +import { goto } from '$app/navigation'; + +import { TUTORIAL_TOUR_ID } from './tutorial-tour.const'; + +/** + * Starts the tutorial. A local editor makes (or reopens) its project, `my-first-game`, and the + * project page starts the tour (`?tour=`). A hosted editor runs the shorter tour on the open + * project; false when none is open. + */ +export const startTutorial = async (services: Container, session: SessionInfo): Promise => { + if (session.mode !== 'OFFLINE') { + if (!services.get(ProjectServiceToken).current.get()) return false; + return services.get(TourServiceToken).start(TUTORIAL_TOUR_ID); + } + const { id } = await services.get(RpcClientToken).api(ProjectsContract).createTutorial({}); + await goto(`/project/${id}?tour=${TUTORIAL_TOUR_ID}`); + return true; +}; diff --git a/apps/editor/src/lib/tutorial/tutorial-tour.const.ts b/apps/editor/src/lib/tutorial/tutorial-tour.const.ts new file mode 100644 index 00000000..0439b25e --- /dev/null +++ b/apps/editor/src/lib/tutorial/tutorial-tour.const.ts @@ -0,0 +1,147 @@ +import type { z } from 'zod'; + +import type { TourSchema } from '@nanoforge-dev/editor-ui'; + +export const TUTORIAL_TOUR_ID = 'tutorial'; + +const LOCAL = 'editor.local'; +const HOSTED = '!editor.local'; + +/** + * The tutorial (_Help › Tutorial_): on its own project (`projects.createTutorial`) in a local + * editor, the screens, an entity added, a value changed, the planted mistake fixed, the game + * played and looked into, then committed. A hosted editor gets the steps that work on any + * project. + */ +export const TUTORIAL_TOUR: z.input = { + id: TUTORIAL_TOUR_ID, + title: 'Tutorial', + steps: [ + { + id: 'welcome', + when: LOCAL, + title: 'Your first game', + text: 'This project has an orange ball that should bounce, and one mistake that keeps it still. The tour shows you around while you fix it. Follow the steps, or go on with Next.', + }, + { + id: 'welcome-hosted', + when: HOSTED, + title: 'A tour of the editor', + text: 'A few steps on this project: the screens, adding an entity, changing a value and editing the code. Follow the steps, or go on with Next.', + }, + { + id: 'screens', + title: 'Screens', + text: 'The editor has a few screens: Scene shows the entities your code makes, Game plays it, Script edits the code and Project holds the apps and settings. Only the main panels are shown at first: View › Panels opens the others.', + target: { role: 'tablist', name: 'Main screens' }, + }, + { + id: 'scene', + title: 'The Scene screen', + text: 'Open the Scene screen.', + target: { role: 'tab', name: 'Scene' }, + advanceWhen: "screen == 'viewport.scene'", + }, + { + id: 'add-entity', + title: 'Add an entity', + text: 'An entity is a thing in your game. Add one with Add entity (+) in the Hierarchy: the editor writes the code for it in main.ts.', + action: { title: 'Show Hierarchy', command: 'workbench.openWidget', args: ['ecs.hierarchy'] }, + target: { role: 'button', name: 'Add entity' }, + advanceWhenChanged: 'ecs.entityCount', + }, + { + id: 'select-ball', + when: LOCAL, + title: 'Select the ball', + text: 'Click ball in the Hierarchy. The Inspector then shows its components: where it is, how it moves and how it is drawn.', + action: { title: 'Show Hierarchy', command: 'workbench.openWidget', args: ['ecs.hierarchy'] }, + target: { role: 'treeitem', name: 'ball', exact: false, within: { role: 'tree', name: 'Entities' } }, + }, + { + id: 'select-hosted', + when: HOSTED, + title: 'Select an entity', + text: 'Click an entity in the Hierarchy. The Inspector then shows its components.', + action: { title: 'Show Hierarchy', command: 'workbench.openWidget', args: ['ecs.hierarchy'] }, + target: { role: 'tree', name: 'Entities' }, + }, + { + id: 'change-value', + when: LOCAL, + title: 'Change a value', + text: 'Change a number in the Inspector, say the ball\'s speed in Velocity2D. The code follows: undo with Ctrl+Z if you want it back.', + target: { role: 'region', name: 'Inspector' }, + advanceWhenChanged: 'ecs.revision', + }, + { + id: 'change-value-hosted', + when: HOSTED, + title: 'Change a value', + text: 'Change a value in the Inspector. The code follows: undo with Ctrl+Z if you want it back.', + target: { role: 'region', name: 'Inspector' }, + advanceWhenChanged: 'ecs.revision', + }, + { + id: 'fix', + when: LOCAL, + title: 'Fix the mistake', + text: 'Problems lists what TypeScript found: move2D reads velocity.speed, which Velocity2D does not have. Open it from Problems and write velocity.x instead.', + target: { role: 'region', name: 'Problems' }, + action: { title: 'Show problems', command: 'console.showProblems' }, + /** Not `!problems.hasErrors`: true too before the first diagnostics. */ + advanceWhen: 'problems.hasErrors == false', + }, + { + id: 'code-hosted', + when: HOSTED, + title: 'Edit the code', + text: 'Open a file from Files to edit it on the Script screen. Problems lists what TypeScript finds, and a click there opens the line.', + target: { role: 'tab', name: 'Script' }, + }, + { + id: 'play', + when: LOCAL, + title: 'Play', + text: 'Play builds the game and runs it on the Game screen. The ball moves now.', + target: { role: 'toolbar', name: 'Run' }, + advanceWhen: "runtime.state == 'running' && runtime.controllable", + }, + { + id: 'no-bridge', + when: `${LOCAL} && runtime.active && !runtime.controllable`, + title: 'A game without the editor bridge', + text: 'The game runs, but its editor library is older than this editor: World, Pause and Stop need the bridge it lacks. Run › Reload editor runtime ends the game.', + }, + { + id: 'world', + when: `${LOCAL} && runtime.controllable`, + title: 'Look inside the running game', + text: 'World shows the entities of the running game and their values, live. Stop the game with Stop when you are done.', + action: { title: 'Show World', command: 'workbench.openWidget', args: ['inspectors.world'] }, + target: { role: 'region', name: 'World' }, + }, + { + id: 'init', + when: `${LOCAL} && !git.repository`, + title: 'Keep your work in git', + text: 'Git keeps every version of your project. Make this project a repository first.', + action: { title: 'Initialize repository', command: 'git.init' }, + advanceWhen: 'git.repository', + }, + { + id: 'commit', + when: `${LOCAL} && git.repository`, + title: 'Commit', + text: 'A commit saves the current version. Tick the files to keep (Unversioned files: all of them), write a message such as "my first game", then Commit.', + action: { title: 'Show Commit', command: 'git.commit' }, + target: { role: 'region', name: 'Commit' }, + advanceWhenChanged: 'git.head', + }, + { + id: 'done', + title: 'Done', + text: 'That is the tour. Help › Tutorial plays it again, and Help › Documentation tells the rest.', + }, + ], +}; diff --git a/apps/editor/src/routes/(editor)/+page.svelte b/apps/editor/src/routes/(editor)/+page.svelte index 43ef1099..923ed411 100644 --- a/apps/editor/src/routes/(editor)/+page.svelte +++ b/apps/editor/src/routes/(editor)/+page.svelte @@ -15,6 +15,8 @@ import { ProjectCachesToken } from '@nanoforge-dev/editor-project'; import { getEditor } from '$lib/editor/editor-context'; + import Welcome from '$lib/tutorial/Welcome.svelte'; + import { TUTORIAL_TOUR_ID } from '$lib/tutorial/tutorial-tour.const'; const editor = getEditor(); const session = editor.session; @@ -63,13 +65,16 @@ .catch(failed); }); - const run = async (label: string, task: () => Promise) => { + /** Opens the project the task gives; true once it did. */ + const run = async (label: string, task: () => Promise): Promise => { error = undefined; busy = label; try { await goto(`/project/${await task()}`); + return true; } catch (reason) { error = reason instanceof Error ? reason.message : String(reason); + return false; } finally { busy = undefined; } @@ -95,6 +100,12 @@ return id; }); + const tutorial = () => + run('Making the tutorial project (nf new, then npm install: a minute or so)', async () => { + const { id } = await projectsApi.createTutorial({}); + return `${id}?tour=${TUTORIAL_TOUR_ID}`; + }); + const opened = (date: Date) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date); @@ -143,6 +154,8 @@ {/if} + + {#if busy}

{busy}…

{/if} {#if error}{/if} diff --git a/apps/editor/src/routes/(editor)/project/[id]/+page.svelte b/apps/editor/src/routes/(editor)/project/[id]/+page.svelte index 589009cc..3a183438 100644 --- a/apps/editor/src/routes/(editor)/project/[id]/+page.svelte +++ b/apps/editor/src/routes/(editor)/project/[id]/+page.svelte @@ -1,7 +1,7 @@ + +{#if !$dismissed.includes(id)} + +{/if} + + diff --git a/packages/ui/src/workbench/parts/TourOverlay.svelte b/packages/ui/src/workbench/parts/TourOverlay.svelte new file mode 100644 index 00000000..32dd97db --- /dev/null +++ b/packages/ui/src/workbench/parts/TourOverlay.svelte @@ -0,0 +1,190 @@ + + +{#if $tour} + {@const { step, position, count } = $tour} + {#if rect} + + {/if} + +{/if} + + diff --git a/packages/ui/src/workbench/parts/WidgetHost.svelte b/packages/ui/src/workbench/parts/WidgetHost.svelte index 6c3cf041..b7110ef8 100644 --- a/packages/ui/src/workbench/parts/WidgetHost.svelte +++ b/packages/ui/src/workbench/parts/WidgetHost.svelte @@ -4,6 +4,7 @@ import Button from '../../components/button.svelte'; import EmptyState from '../../components/empty-state.svelte'; import { getWorkbenchContext } from '../workbench-context'; + import PanelTip from './PanelTip.svelte'; interface Props { ref: WidgetRef; @@ -34,6 +35,9 @@ data-nf-history={$status.kind === 'ready' ? $status.descriptor.historyContext : undefined} hidden={!active} > + {#if $status.kind === 'ready' && $status.descriptor.tip && $status.descriptor.kind === 'dock'} + + {/if} {#if $status.kind === 'ready'} {@const view = $status.view} diff --git a/packages/ui/src/workbench/setup/setup-shell.ts b/packages/ui/src/workbench/setup/setup-shell.ts index 458b33bf..c2711dbe 100644 --- a/packages/ui/src/workbench/setup/setup-shell.ts +++ b/packages/ui/src/workbench/setup/setup-shell.ts @@ -32,6 +32,8 @@ import { KEYMAP_PRESETS, KeymapPresetSchema } from '../keybinding/keymap-presets import type { KeymapOverride } from '../keybinding/keymap.type'; import { NotificationService } from '../notification/notification-service'; import { PromptService } from '../prompt/prompt-service'; +import { TourService } from '../tour/tour-service'; +import { TOURS, type Tour, TourSchema } from '../tour/tour.extension-point'; export interface ShellOptions { readonly extensions: ExtensionRegistry; @@ -49,6 +51,7 @@ export interface Shell extends Disposable { readonly notifications: NotificationService; readonly prompts: PromptService; readonly keybindings: KeybindingService; + readonly tours: TourService; } /** Manifest handler registering each declared value on an extension point. */ @@ -78,6 +81,16 @@ export const setupShell = (options: ShellOptions): Shell => { ); const notifications = new NotificationService(); const prompts = new PromptService(); + const tours = store.add(new TourService(extensions, contextKeys, commands, notifications)); + store.add( + commands.register({ + id: 'tour.start', + title: 'Start a tour', + category: 'Help', + palette: false, + handler: (_services, id: unknown) => typeof id === 'string' && tours.start(id), + }), + ); const keybindings = store.add( new KeybindingService( extensions, @@ -161,6 +174,13 @@ export const setupShell = (options: ShellOptions): Shell => { ), ), ); + store.add( + host.registerContributionHandler( + listHandler('tours', TourSchema as unknown as z.ZodType, (value, owner) => + extensions.contribute(TOURS, value, { owner }), + ), + ), + ); store.add( host.registerContributionHandler( listHandler('themes', z.unknown() as z.ZodType, (value, owner) => @@ -170,5 +190,13 @@ export const setupShell = (options: ShellOptions): Shell => { ); } - return { styles, themes, notifications, prompts, keybindings, dispose: () => store.dispose() }; + return { + styles, + themes, + notifications, + prompts, + keybindings, + tours, + dispose: () => store.dispose(), + }; }; diff --git a/packages/ui/src/workbench/setup/setup-workbench.ts b/packages/ui/src/workbench/setup/setup-workbench.ts index 5a19cec3..b03d71f4 100644 --- a/packages/ui/src/workbench/setup/setup-workbench.ts +++ b/packages/ui/src/workbench/setup/setup-workbench.ts @@ -130,32 +130,12 @@ export const setupWorkbench = (options: WorkbenchSetupOptions): WorkbenchContext * else starts simple. Writing it also for a new user keeps their first saved layout from making * them look like an existing one on the next load. */ - let preset = settings.get(CoreSettings.layoutPreset); if (settings.inspect(CoreSettings.layoutPreset).effectiveScope === 'default') { - preset = !layout.firstRun || options.knownUser ? 'full' : 'simple'; + const preset = !layout.firstRun || options.knownUser ? 'full' : 'simple'; void settings.set(CoreSettings.layoutPreset, preset, presetScope()).catch(() => undefined); if (layout.firstRun && preset === 'full') void layout.reset('full'); } - /** Said once per browser: the default layout is composed again until something is saved. */ - const NOTICE_KEY = 'nanoforge.layout.simpleNoticeShown'; - const noticeShown = () => { - try { - return localStorage.getItem(NOTICE_KEY) !== null; - } catch { - return true; - } - }; - if (layout.firstRun && preset === 'simple' && !noticeShown()) { - try { - localStorage.setItem(NOTICE_KEY, '1'); - } catch {} - shell.notifications.notify('info', 'Only the main panels are shown', { - detail: 'View › Panels opens the others, or show them all.', - actions: [{ title: 'Show all panels', run: () => usePreset('full') }], - }); - } - const dynamic = store.add(new DisposableStore()); const refreshMenus = () => { dynamic.clear(); @@ -205,6 +185,14 @@ export const setupWorkbench = (options: WorkbenchSetupOptions): WorkbenchContext observe(settings.observe(CoreSettings.layoutSaved), refreshMenus, { immediate: false }), ); + /** `screen`: the screen shown (`when` clauses, tour steps). */ + store.add({ + dispose: layout.layout.subscribe((current) => + options.contextKeys.set('screen', current.activeScreen ?? ''), + ), + }); + options.contextKeys.set('screen', layout.current.activeScreen ?? ''); + return { workbench, layout, @@ -212,6 +200,8 @@ export const setupWorkbench = (options: WorkbenchSetupOptions): WorkbenchContext notifications: shell.notifications, prompts: shell.prompts, keybindings: shell.keybindings, + tours: shell.tours, + settings, extensions, commands, contextKeys: options.contextKeys, diff --git a/packages/ui/src/workbench/tip/docs-url.const.ts b/packages/ui/src/workbench/tip/docs-url.const.ts new file mode 100644 index 00000000..48d8d1ec --- /dev/null +++ b/packages/ui/src/workbench/tip/docs-url.const.ts @@ -0,0 +1,5 @@ +/** The documentation site (the editor's pages are under `/docs/`). */ +export const DOCS_URL = 'https://docs.nanoforge.eu'; + +/** The URL of a page of the editor's documentation (`editor/entities`). */ +export const docsPage = (page: string): string => `${DOCS_URL}/docs/${page}`; diff --git a/packages/ui/src/workbench/tip/index.ts b/packages/ui/src/workbench/tip/index.ts new file mode 100644 index 00000000..ca93f9af --- /dev/null +++ b/packages/ui/src/workbench/tip/index.ts @@ -0,0 +1 @@ +export * from './docs-url.const'; diff --git a/packages/ui/src/workbench/tour/find-target.ts b/packages/ui/src/workbench/tour/find-target.ts new file mode 100644 index 00000000..ef17d10f --- /dev/null +++ b/packages/ui/src/workbench/tour/find-target.ts @@ -0,0 +1,57 @@ +import type { z } from 'zod'; + +import type { TourTargetSchema } from './tour.extension-point'; + +/** Elements that have a role without saying it (the roles tours point at). */ +const IMPLICIT: Record = { + button: 'button:not([role]), input[type="button"]:not([role]), input[type="submit"]:not([role])', + link: 'a[href]:not([role])', + textbox: + 'input:not([type]):not([role]), input[type="text"]:not([role]), input[type="search"]:not([role]), textarea:not([role])', + checkbox: 'input[type="checkbox"]:not([role])', + navigation: 'nav:not([role])', + region: 'section[aria-label]:not([role]), section[aria-labelledby]:not([role])', + heading: 'h1:not([role]), h2:not([role]), h3:not([role]), h4:not([role])', + complementary: 'aside:not([role])', +}; + +/** The accessible name of an element, near enough: label, labelledby, then its text. */ +export const accessibleName = (element: Element): string => { + const label = element.getAttribute('aria-label'); + if (label) return label.trim(); + const ids = element.getAttribute('aria-labelledby'); + if (ids) + return ids + .split(/\s+/) + .map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim() ?? '') + .join(' ') + .trim(); + return (element.textContent ?? '').replace(/\s+/g, ' ').trim(); +}; + +const visible = (element: Element) => + element.getClientRects().length > 0 && + !element.closest('[inert], [hidden], [aria-hidden="true"]'); + +const byRole = ( + root: ParentNode, + role: string, + name: string, + exact = true, +): HTMLElement | undefined => { + const selector = [`[role="${role}"]`, IMPLICIT[role]].filter(Boolean).join(', '); + const matches = (actual: string) => + actual === name || (!exact && actual.startsWith(name) && /^\W/.test(actual.slice(name.length))); + return [...root.querySelectorAll(selector)].find( + (element) => visible(element) && matches(accessibleName(element)), + ); +}; + +/** The element a step points at, when it is on screen. */ +export const findTarget = ( + target: z.input, + root: ParentNode = document, +): HTMLElement | undefined => { + const scope = target.within ? byRole(root, target.within.role, target.within.name) : root; + return scope ? byRole(scope, target.role, target.name, target.exact) : undefined; +}; diff --git a/packages/ui/src/workbench/tour/index.ts b/packages/ui/src/workbench/tour/index.ts new file mode 100644 index 00000000..8308b20c --- /dev/null +++ b/packages/ui/src/workbench/tour/index.ts @@ -0,0 +1,3 @@ +export * from './tour.extension-point'; +export * from './tour-service'; +export { accessibleName, findTarget } from './find-target'; diff --git a/packages/ui/src/workbench/tour/tour-service.ts b/packages/ui/src/workbench/tour/tour-service.ts new file mode 100644 index 00000000..e017d7d1 --- /dev/null +++ b/packages/ui/src/workbench/tour/tour-service.ts @@ -0,0 +1,149 @@ +import { + type CommandRegistry, + type ContextKeyService, + type Disposable, + DisposableStore, + type ExtensionRegistry, + type Observable, + ObservableValue, + createToken, +} from '@nanoforge-dev/editor-kernel'; + +import { notifyError } from '../notification/describe-error'; +import type { NotificationService } from '../notification/notification-service'; +import { TOURS, type Tour, type TourStep } from './tour.extension-point'; + +/** The tour shown, and its step. */ +export interface TourState { + readonly tour: Tour; + readonly step: TourStep; + /** Position among the steps shown in this editor (steps whose `when` is false are left out). */ + readonly position: number; + readonly count: number; +} + +/** + * Runs tours (`TOURS`): one step at a time, forward and back, ahead by itself when a step's + * condition is met (`advanceWhen`, `advanceWhenChanged`). Steps whose `when` is false here (a + * hosted editor, no project) are skipped. + */ +export class TourService implements Disposable { + private readonly _state = new ObservableValue(undefined); + private readonly _store = new DisposableStore(); + private _index = 0; + /** + * The steps counted in "n of m": those that applied when the tour started, and those shown + * since (the count never shrinks while the tour goes). + */ + private _counted = new Set(); + /** The value of the step's `advanceWhenChanged` key when the step began. */ + private _initial: unknown; + /** + * The step's `advanceWhen` held when Back reached it: it goes on only once the condition + * turns false then true again, or Back would bounce straight forward. + */ + private _metOnArrival = false; + + constructor( + private readonly _extensions: ExtensionRegistry, + private readonly _contextKeys: ContextKeyService, + private readonly _commands: CommandRegistry, + private readonly _notifications?: NotificationService, + ) { + this._store.add(this._contextKeys.onDidChange(() => this._check())); + } + + get state(): Observable { + return this._state.readonly(); + } + + tours(): Tour[] { + return this._extensions.getValues(TOURS); + } + + /** Starts a tour from its first step; false when there is no such tour or no step to show. */ + start(id: string): boolean { + const tour = this.tours().find((candidate) => candidate.id === id); + if (!tour) return false; + this._counted = new Set(tour.steps.filter((step) => this._contextKeys.evaluate(step.when))); + return this._go(tour, 0, 1); + } + + next(): void { + const state = this._state.get(); + if (!state) return; + if (!this._go(state.tour, this._index + 1, 1)) this.stop(); + } + + back(): void { + const state = this._state.get(); + if (state) this._go(state.tour, this._index - 1, -1); + } + + stop(): void { + this._state.set(undefined); + } + + /** Runs the step's action (its command); a failure is shown as a notification. */ + async act(): Promise { + const action = this._state.get()?.step.action; + if (!action) return; + try { + await this._commands.execute(action.command, ...action.args); + } catch (error) { + notifyError(this._notifications, `${action.title} failed`, error); + } + } + + dispose(): void { + this._store.dispose(); + this._state.set(undefined); + } + + /** Shows the first step from `index` (in `direction`) whose `when` holds; false if none. */ + private _go(tour: Tour, index: number, direction: 1 | -1): boolean { + for (let i = index; i >= 0 && i < tour.steps.length; i += direction) { + const step = tour.steps[i]!; + if (!this._contextKeys.evaluate(step.when)) continue; + this._index = i; + this._counted.add(step); + this._initial = step.advanceWhenChanged + ? this._contextKeys.get(step.advanceWhenChanged) + : undefined; + this._metOnArrival = + direction < 0 && + step.advanceWhen !== undefined && + this._contextKeys.evaluate(step.advanceWhen); + const counted = tour.steps.filter((candidate) => this._counted.has(candidate)); + const position = tour.steps + .slice(0, i + 1) + .filter((candidate) => this._counted.has(candidate)).length; + this._state.set({ + tour, + step, + position: Math.max(1, position), + count: Math.max(counted.length, position), + }); + this._check(); + return true; + } + return false; + } + + private _check(): void { + const state = this._state.get(); + if (!state) return; + const { step } = state; + const key = step.advanceWhenChanged; + const value = key === undefined ? undefined : this._contextKeys.get(key); + /** A key not set yet when the step began (its plugin was not up): its first value is the start. */ + if (key !== undefined && this._initial === undefined) this._initial = value; + const changed = key !== undefined && value !== this._initial; + const holds = step.advanceWhen !== undefined && this._contextKeys.evaluate(step.advanceWhen); + const met = holds && !this._metOnArrival; + if (!holds) this._metOnArrival = false; + if (changed || met) queueMicrotask(() => this._state.get()?.step === step && this.next()); + } +} + +export const TourServiceToken = createToken('ui.tours'); diff --git a/packages/ui/src/workbench/tour/tour.extension-point.ts b/packages/ui/src/workbench/tour/tour.extension-point.ts new file mode 100644 index 00000000..069df694 --- /dev/null +++ b/packages/ui/src/workbench/tour/tour.extension-point.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +import { type ExtensionPoint, defineExtensionPoint } from '@nanoforge-dev/editor-kernel'; + +/** What a step points at: an element found by role and accessible name, as tests find them. */ +export const TourTargetSchema = z.object({ + role: z.string().min(1), + name: z.string().min(1), + /** False: the name only starts with `name` (a tree row says more than its label). */ + exact: z.boolean().default(true), + /** Inside the element of this role and name (a panel, a list). */ + within: z.object({ role: z.string().min(1), name: z.string().min(1) }).optional(), +}); +export type TourTarget = z.output; + +export const TourStepSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + text: z.string().min(1), + target: TourTargetSchema.optional(), + /** Shown only while true (`when` clause): steps that need a local editor, a project… */ + when: z.string().optional(), + /** Goes to the next step by itself once true (`when` clause). */ + advanceWhen: z.string().optional(), + /** Goes to the next step by itself once this context key's value changes. */ + advanceWhenChanged: z.string().optional(), + /** A button of the step that runs a command (Open the Scenes panel…). */ + action: z + .object({ + title: z.string().min(1), + command: z.string().min(1), + args: z.array(z.unknown()).default([]), + }) + .optional(), +}); +export type TourStep = z.output; + +/** A guided tour: steps shown one at a time over the editor (`contributes.tours`). */ +export const TourSchema = z.object({ + id: z.string().regex(/^[a-z0-9][\w.-]*$/), + title: z.string().min(1), + steps: z.array(TourStepSchema).min(1), +}); +export type Tour = z.output; + +export const TOURS: ExtensionPoint = defineExtensionPoint('ui.tours', { + validator: TourSchema as unknown as z.ZodType, +}); diff --git a/packages/ui/src/workbench/workbench-context.ts b/packages/ui/src/workbench/workbench-context.ts index 51f01812..b35d188b 100644 --- a/packages/ui/src/workbench/workbench-context.ts +++ b/packages/ui/src/workbench/workbench-context.ts @@ -6,6 +6,7 @@ import type { ExtensionRegistry, Logger, } from '@nanoforge-dev/editor-kernel'; +import type { SettingsService } from '@nanoforge-dev/editor-settings'; import type { KeybindingService } from './keybinding/keybinding-service'; import type { DragController } from './layout/drag-controller'; @@ -14,6 +15,7 @@ import type { MenuContext } from './menu/resolve-menu'; import type { NotificationService } from './notification/notification-service'; import type { PromptService } from './prompt/prompt-service'; import type { WorkbenchService } from './service/workbench-service'; +import type { TourService } from './tour/tour-service'; export interface WorkbenchContext extends MenuContext { readonly workbench: WorkbenchService; @@ -25,6 +27,8 @@ export interface WorkbenchContext extends MenuContext { readonly commands: CommandRegistry; readonly contextKeys: ContextKeyService; readonly keybindings: KeybindingService; + readonly tours: TourService; + readonly settings: SettingsService; readonly logger: Logger; } diff --git a/packages/ui/src/workbench/workbench.svelte b/packages/ui/src/workbench/workbench.svelte index a780325f..0840cd6a 100644 --- a/packages/ui/src/workbench/workbench.svelte +++ b/packages/ui/src/workbench/workbench.svelte @@ -15,6 +15,7 @@ import StatusBar from './parts/StatusBar.svelte'; import ToolStripe from './parts/ToolStripe.svelte'; import TopBar from './parts/TopBar.svelte'; + import TourOverlay from './parts/TourOverlay.svelte'; import WidgetHost from './parts/WidgetHost.svelte'; const { context, brand }: { context: WorkbenchContext; brand?: Snippet } = $props(); @@ -330,6 +331,7 @@ + diff --git a/packages/ui/test/workbench/tour/find-target.svelte.spec.ts b/packages/ui/test/workbench/tour/find-target.svelte.spec.ts new file mode 100644 index 00000000..2f8a0b82 --- /dev/null +++ b/packages/ui/test/workbench/tour/find-target.svelte.spec.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { findTarget } from '../../../src/workbench/tour/find-target'; + +afterEach(() => document.body.replaceChildren()); + +describe('findTarget', () => { + it('finds elements by role and accessible name, as the e2e tests do', () => { + document.body.innerHTML = ` + +
+
+ Search files + + `; + expect(findTarget({ role: 'tab', name: 'Files' })?.textContent).toBe('F'); + const hierarchy = findTarget({ + role: 'button', + name: 'Add entity', + within: { role: 'region', name: 'Hierarchy' }, + }); + expect(hierarchy?.closest('section')?.getAttribute('aria-label')).toBe('Hierarchy'); + expect(findTarget({ role: 'textbox', name: 'Search files' })?.tagName).toBe('INPUT'); + expect(findTarget({ role: 'button', name: 'Play' })).toBeUndefined(); + expect(findTarget({ role: 'button', name: 'Nothing' })).toBeUndefined(); + document.body.insertAdjacentHTML( + 'beforeend', + '
ball Position2D
balloon
', + ); + expect(findTarget({ role: 'treeitem', name: 'ball' })).toBeUndefined(); + expect(findTarget({ role: 'treeitem', name: 'ball', exact: false })?.textContent).toBe( + 'ball Position2D', + ); + expect(findTarget({ role: 'treeitem', name: 'balloon', exact: false })?.textContent).toBe( + 'balloon', + ); + }); +}); diff --git a/packages/ui/test/workbench/tour/tour-service.spec.ts b/packages/ui/test/workbench/tour/tour-service.spec.ts new file mode 100644 index 00000000..ee51de18 --- /dev/null +++ b/packages/ui/test/workbench/tour/tour-service.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; + +import { CoreServices, createCoreContainer } from '@nanoforge-dev/editor-kernel'; + +import { NotificationService } from '../../../src/workbench/notification/notification-service'; +import { TourService } from '../../../src/workbench/tour/tour-service'; +import { TOURS, type Tour } from '../../../src/workbench/tour/tour.extension-point'; + +const setup = (tour: Tour) => { + const container = createCoreContainer(); + const extensions = container.get(CoreServices.Extensions); + const contextKeys = container.get(CoreServices.ContextKeys); + const commands = container.get(CoreServices.Commands); + const notifications = new NotificationService(); + extensions.contribute(TOURS, tour, { owner: 'test' }); + const tours = new TourService(extensions, contextKeys, commands, notifications); + const title = () => tours.state.get()?.step.title; + const settle = () => new Promise((resolve) => queueMicrotask(() => resolve(undefined))); + return { tours, contextKeys, commands, notifications, title, settle }; +}; + +const step = (id: string, extra: object = {}) => ({ id, title: id, text: `About ${id}`, ...extra }); + +describe('TourService', () => { + it('goes forward and back, skips steps that do not apply, and ends after the last', () => { + const { tours, contextKeys, title } = setup({ + id: 'demo', + title: 'Demo', + steps: [step('one'), step('local', { when: 'editor.local' }), step('two')], + }); + expect(tours.start('demo')).toBe(true); + expect(tours.state.get()).toMatchObject({ position: 1, count: 2 }); + tours.next(); + expect(title()).toBe('two'); + tours.back(); + expect(title()).toBe('one'); + contextKeys.set('editor.local', true); + tours.next(); + expect(title()).toBe('local'); + tours.next(); + tours.next(); + expect(tours.state.get()).toBeUndefined(); + expect(tours.start('nope')).toBe(false); + }); + + it('goes on by itself when the step condition is met or its key changes', async () => { + const { tours, contextKeys, title, settle } = setup({ + id: 'demo', + title: 'Demo', + steps: [ + step('play', { advanceWhen: "runtime.state == 'running'" }), + step('add', { advanceWhenChanged: 'ecs.entityCount' }), + step('end'), + ], + }); + contextKeys.set('ecs.entityCount', 3); + tours.start('demo'); + contextKeys.set('runtime.state', 'running'); + await settle(); + expect(title()).toBe('add'); + contextKeys.set('ecs.entityCount', 3); + await settle(); + expect(title()).toBe('add'); + contextKeys.set('ecs.entityCount', 4); + await settle(); + expect(title()).toBe('end'); + }); + + it('waits for a key that is not set yet instead of taking its first value as a change', async () => { + const { tours, contextKeys, title, settle } = setup({ + id: 'demo', + title: 'Demo', + steps: [step('commit', { advanceWhenChanged: 'git.head' }), step('end')], + }); + tours.start('demo'); + contextKeys.set('git.head', 'abc'); + await settle(); + expect(title()).toBe('commit'); + contextKeys.set('git.head', 'def'); + await settle(); + expect(title()).toBe('end'); + }); + + it('keeps the count of the steps that applied at the start', () => { + const { tours, contextKeys } = setup({ + id: 'demo', + title: 'Demo', + steps: [step('init', { when: '!git.repository' }), step('commit'), step('end')], + }); + tours.start('demo'); + expect(tours.state.get()).toMatchObject({ position: 1, count: 3 }); + contextKeys.set('git.repository', true); + tours.next(); + expect(tours.state.get()).toMatchObject({ position: 2, count: 3 }); + }); + + it('stays on a step reached by Back until its condition turns true again', async () => { + const { tours, contextKeys, title, settle } = setup({ + id: 'demo', + title: 'Demo', + steps: [step('scene', { advanceWhen: "screen == 'scene'" }), step('add'), step('end')], + }); + tours.start('demo'); + contextKeys.set('screen', 'scene'); + await settle(); + expect(title()).toBe('add'); + tours.back(); + await settle(); + expect(title()).toBe('scene'); + contextKeys.set('other', 1); + await settle(); + expect(title()).toBe('scene'); + contextKeys.set('screen', 'game'); + await settle(); + expect(title()).toBe('scene'); + contextKeys.set('screen', 'scene'); + await settle(); + expect(title()).toBe('add'); + }); + + it('shows a failed action as a notification instead of rejecting', async () => { + const { tours, commands, notifications } = setup({ + id: 'demo', + title: 'Demo', + steps: [step('commit', { action: { title: 'Show Commit', command: 'test.fails' } })], + }); + commands.register({ + id: 'test.fails', + title: 'Fails', + when: 'git.repository', + handler: () => undefined, + }); + tours.start('demo'); + await expect(tours.act()).resolves.toBeUndefined(); + expect(notifications.active.get()).toMatchObject([ + { severity: 'error', message: 'Show Commit failed' }, + ]); + }); +}); diff --git a/plugins/console/nanoforge.manifest.json b/plugins/console/nanoforge.manifest.json index 88cb0fba..d3651a68 100644 --- a/plugins/console/nanoforge.manifest.json +++ b/plugins/console/nanoforge.manifest.json @@ -12,6 +12,10 @@ "widgets": [ { "id": "console.console", + "tip": { + "text": "What your game, its builds and the editor print. The buttons above filter it by group and level, and the search by text.", + "docs": "editor/play" + }, "essential": true, "title": "Console", "icon": "square-terminal", @@ -20,6 +24,10 @@ }, { "id": "console.problems", + "tip": { + "text": "Errors and warnings in your code, found as you type. Click one to open its line.", + "docs": "editor/code-editor" + }, "title": "Problems", "icon": "circle-alert", "defaultSlot": "bottom", diff --git a/plugins/console/src/index.ts b/plugins/console/src/index.ts index 0644c025..c8ca2446 100644 --- a/plugins/console/src/index.ts +++ b/plugins/console/src/index.ts @@ -72,6 +72,22 @@ export default definePlugin({ ); const diagnostics = services.get(DiagnosticsServiceToken); + /** + * For tours: whether the project's code has errors. Unset until diagnostics come, so that "no + * errors yet" does not read as "fixed". + */ + const contextKeys = services.get(EditorServices.ContextKeys); + let checked = false; + context.subscriptions.add({ + dispose: diagnostics.all.subscribe((all) => { + checked ||= all.size > 0; + if (!checked) return; + contextKeys.set( + 'problems.hasErrors', + [...all.values()].some((list) => list.some((problem) => problem.severity === 'error')), + ); + }), + }); context.subscriptions.add( context.contribute(STATUS_BAR_ITEMS, { id: 'console.problems', diff --git a/plugins/ecs/nanoforge.manifest.json b/plugins/ecs/nanoforge.manifest.json index d3657903..a4cce28d 100644 --- a/plugins/ecs/nanoforge.manifest.json +++ b/plugins/ecs/nanoforge.manifest.json @@ -38,6 +38,10 @@ "widgets": [ { "id": "ecs.hierarchy", + "tip": { + "text": "The entities your app's main.ts creates. + adds one; select one to see its components in the Inspector.", + "docs": "editor/entities" + }, "essential": true, "title": "Hierarchy", "icon": "list-tree", @@ -46,6 +50,10 @@ }, { "id": "ecs.inspector", + "tip": { + "text": "The components of the selected entity. A change here is written into your code, and Ctrl+Z undoes it.", + "docs": "editor/components" + }, "essential": true, "title": "Inspector", "icon": "sliders-horizontal", @@ -54,6 +62,10 @@ }, { "id": "ecs.browser", + "tip": { + "text": "The components (C) and systems (S) the selected app can use: its own, its shared libraries' and its installed packages'.", + "docs": "editor/components" + }, "title": "Components", "icon": "blocks", "defaultSlot": "leftBottom", @@ -61,6 +73,10 @@ }, { "id": "ecs.systems", + "tip": { + "text": "The systems the selected app runs, in run order, with the components each one reads.", + "docs": "editor/systems" + }, "title": "Systems", "icon": "settings", "defaultSlot": "leftBottom", diff --git a/plugins/ecs/src/index.ts b/plugins/ecs/src/index.ts index f75ae349..51f0d4c2 100644 --- a/plugins/ecs/src/index.ts +++ b/plugins/ecs/src/index.ts @@ -100,6 +100,29 @@ export default definePlugin({ context.subscriptions.add(registerIcons({ move: Move })); const contextKeys = context.services.get(EditorServices.ContextKeys); context.subscriptions.add(observe(moveInGame, (on) => contextKeys.set(MOVE_IN_GAME_KEY, on))); + /** For tours: the entities of the edited code, and a count bumped when it is edited. */ + let revision = 0; + let signature: string | undefined; + contextKeys.set('ecs.revision', revision); + context.subscriptions.add( + observe(ecs.model, (model) => { + /** Only an edit counts, not the same code analyzed again nor a model loading. */ + if (!model) { + signature = undefined; + return; + } + contextKeys.set('ecs.entityCount', model.entities.length); + const next = JSON.stringify( + model.entities.map((entity) => [ + entity.name, + entity.components.map((use) => use.args.map((arg) => arg.code)), + ]), + ); + if (signature !== undefined && next !== signature) + contextKeys.set('ecs.revision', ++revision); + signature = next; + }), + ); context.subscriptions.add({ dispose: () => moveInGame.set(false) }); context.registerCommand('ecs.toggleMoveInGame', () => moveInGame.set(!moveInGame.get()), { title: 'Move entities in the game', diff --git a/plugins/ecs/src/scene/build-scene.ts b/plugins/ecs/src/scene/build-scene.ts index 59404a91..cf8685d4 100644 --- a/plugins/ecs/src/scene/build-scene.ts +++ b/plugins/ecs/src/scene/build-scene.ts @@ -1,7 +1,9 @@ /** * The 2D scene of an app (spec: "Scene screen"), drawn from its entry file: each entity's * position (a component with numeric `x` and `y` params) and its graphics-2d shapes (`Rect`, - * `Circle` arguments written as literals). While playing, positions come from the live world. + * `Circle` arguments written as literals, or a component with a `radius`, or `width` and + * `height`, param: `nf new`'s `DrawableCircle2D`). While playing, positions come from the live + * world. */ import type { CatalogItem } from '@nanoforge-dev/editor-sdk'; @@ -69,30 +71,64 @@ export const buildScene = ( y: argNumber(use.args[yParam], defaults[yParam]?.default), }; }); + const at = (shape: Omit, dx = 0, dy = 0) => + shapes.push({ + entity: entity.name, + ...shape, + x: (position?.x ?? 0) + dx, + y: (position?.y ?? 0) + dy, + ...(position && { + position: { + component: position.component, + componentName: position.componentName, + xParam: position.xParam, + yParam: position.yParam, + }, + }), + }); + /** An entity drawn by a `Rect` or `Circle` is not guessed from params too (a hitbox's size). */ + const drawn = entity.components.some((use) => + use.args.some((arg) => arg.construct && SHAPES[arg.construct.className]), + ); for (const use of entity.components) { + if (!drawn) { + const params = itemOf(use.item)?.meta.params ?? []; + const index = (...names: string[]) => + params.findIndex((param) => names.includes(param.name)); + const value = (at: number, fallback: number) => { + if (at < 0) return undefined; + const arg = use.args[at]; + const raw = + arg?.value !== undefined ? arg.value : (params[at] as { default?: unknown }).default; + return typeof raw === 'number' ? raw : fallback; + }; + const fill = index('fillColor', 'fill', 'color'); + const colors = { + ...(fill >= 0 && str(use.args[fill]?.value) && { fill: str(use.args[fill]?.value) }), + }; + const radius = value(index('radius'), 25); + const width = value(index('width'), 50); + const height = value(index('height'), 50); + if (radius !== undefined) at({ kind: 'circle', width: 50, height: 50, radius, ...colors }); + else if (width !== undefined && height !== undefined) + at({ kind: 'rect', width, height, radius: 25, ...colors }); + } for (const arg of use.args) { const kind = arg.construct ? SHAPES[arg.construct.className] : undefined; if (!kind || !arg.construct) continue; const config = (arg.construct.args[0] ?? {}) as Record; - shapes.push({ - entity: entity.name, - kind, - x: (position?.x ?? 0) + num(config.x), - y: (position?.y ?? 0) + num(config.y), - width: num(config.width, 50), - height: num(config.height, 50), - radius: num(config.radius, 25), - ...(str(config.fill) && { fill: str(config.fill) }), - ...(str(config.stroke) && { stroke: str(config.stroke) }), - ...(position && { - position: { - component: position.component, - componentName: position.componentName, - xParam: position.xParam, - yParam: position.yParam, - }, - }), - }); + at( + { + kind, + width: num(config.width, 50), + height: num(config.height, 50), + radius: num(config.radius, 25), + ...(str(config.fill) && { fill: str(config.fill) }), + ...(str(config.stroke) && { stroke: str(config.stroke) }), + }, + num(config.x), + num(config.y), + ); } } } diff --git a/plugins/ecs/test/scene/build-scene.spec.ts b/plugins/ecs/test/scene/build-scene.spec.ts index 8f21b1e9..0c225147 100644 --- a/plugins/ecs/test/scene/build-scene.spec.ts +++ b/plugins/ecs/test/scene/build-scene.spec.ts @@ -118,4 +118,117 @@ describe('2D scene', () => { ]); expect([live[1]!.x, live[1]!.y]).toEqual([900, 5]); }); + + it("draws components that take a shape's size as params (nf new's DrawableCircle2D)", () => { + const arg = (value: unknown) => ({ code: JSON.stringify(value), start: 0, end: 0, value }); + const params = (...names: string[]) => ({ + meta: { + params: names.map((name) => ({ name, type: name === 'fillColor' ? 'string' : 'number' })), + }, + }); + const items: Record = { + position: params('x', 'y'), + circle: params('radius', 'visible', 'fillColor'), + box: params('width', 'height'), + }; + const model = { + entities: [ + { + name: 'ball', + components: [ + { item: 'position', className: 'Position2D', args: [arg(300), arg(200)] }, + { + item: 'circle', + className: 'DrawableCircle2D', + args: [arg(40), arg(true), arg('orange')], + }, + ], + }, + { + name: 'wall', + components: [{ item: 'box', className: 'Box', args: [arg(10), arg(80)] }], + }, + ], + } as unknown as EntryModel; + const shapes = buildScene( + model, + (ref) => items[ref ?? ''] as never, + (_item, name) => name ?? '', + ); + expect(shapes).toMatchObject([ + { + entity: 'ball', + kind: 'circle', + x: 300, + y: 200, + radius: 40, + fill: 'orange', + position: { component: 0 }, + }, + { entity: 'wall', kind: 'rect', x: 0, y: 0, width: 10, height: 80 }, + ]); + }); + + it('draws an entity with a Rect or Circle once, not its hitbox too', () => { + const arg = (value: unknown) => ({ code: JSON.stringify(value), start: 0, end: 0, value }); + const items: Record = { + position: { + meta: { + params: [ + { name: 'x', type: 'number' }, + { name: 'y', type: 'number' }, + ], + }, + }, + box: { + meta: { + params: [ + { name: 'width', type: 'number' }, + { name: 'height', type: 'number' }, + ], + }, + }, + visual: { meta: { params: [{ name: 'shape', type: 'object' }] } }, + }; + const model = { + entities: [ + { + name: 'ball', + components: [ + { item: 'position', className: 'Position', args: [arg(100), arg(50)] }, + { item: 'box', className: 'Box', args: [arg(18), arg(18)] }, + { + item: 'visual', + className: 'Visual', + args: [ + { + code: 'new Rect({ width: 18, height: 18, cornerRadius: 9 })', + start: 0, + end: 0, + construct: { + className: 'Rect', + args: [{ width: 18, height: 18, cornerRadius: 9, fill: 'orange' }], + }, + }, + ], + }, + ], + }, + { + name: 'flat', + components: [{ item: 'box', className: 'Box', args: [arg(0), arg(30)] }], + }, + ], + } as unknown as EntryModel; + const shapes = buildScene( + model, + (ref) => items[ref ?? ''] as never, + (_item, name) => name ?? '', + ); + expect(shapes).toMatchObject([ + { entity: 'ball', kind: 'rect', x: 100, y: 50, width: 18, height: 18, fill: 'orange' }, + { entity: 'flat', kind: 'rect', width: 0, height: 30 }, + ]); + expect(shapes).toHaveLength(2); + }); }); diff --git a/plugins/file-manager/nanoforge.manifest.json b/plugins/file-manager/nanoforge.manifest.json index 36d3450d..52400cb1 100644 --- a/plugins/file-manager/nanoforge.manifest.json +++ b/plugins/file-manager/nanoforge.manifest.json @@ -20,6 +20,10 @@ "widgets": [ { "id": "file-manager.files", + "tip": { + "text": "Your project's folders and files. Double-click a file to open it; + or a right-click on a folder creates one.", + "docs": "editor/files" + }, "essential": true, "title": "Files", "icon": "folder-tree", diff --git a/plugins/git/nanoforge.manifest.json b/plugins/git/nanoforge.manifest.json index 7a9a706d..e95efde9 100644 --- a/plugins/git/nanoforge.manifest.json +++ b/plugins/git/nanoforge.manifest.json @@ -12,6 +12,10 @@ "widgets": [ { "id": "git.changes", + "tip": { + "text": "What changed since the last commit. Tick the files to keep, write a message, then Commit.", + "docs": "editor/git" + }, "title": "Commit", "icon": "check", "defaultSlot": "leftTop", @@ -19,6 +23,10 @@ }, { "id": "git.log", + "tip": { + "text": "Branches, their commits, and the files each commit changed. Double-click a branch to check it out.", + "docs": "editor/git" + }, "title": "Git", "icon": "git-branch", "defaultSlot": "bottom", diff --git a/plugins/git/src/index.ts b/plugins/git/src/index.ts index e3b6351d..987252b8 100644 --- a/plugins/git/src/index.ts +++ b/plugins/git/src/index.ts @@ -82,9 +82,10 @@ export default definePlugin({ context.subscriptions.add({ dispose: () => window.removeEventListener('focus', onFocus) }); context.subscriptions.add({ - dispose: store.state.subscribe(({ status }) => - contextKeys.set('git.repository', status?.repository === true), - ), + dispose: store.state.subscribe(({ status }) => { + contextKeys.set('git.repository', status?.repository === true); + contextKeys.set('git.head', status?.head ?? ''); + }), }); for (const [id, component] of [ diff --git a/plugins/history-panel/nanoforge.manifest.json b/plugins/history-panel/nanoforge.manifest.json index bb46efad..5d27f183 100644 --- a/plugins/history-panel/nanoforge.manifest.json +++ b/plugins/history-panel/nanoforge.manifest.json @@ -12,6 +12,10 @@ "widgets": [ { "id": "history-panel.history", + "tip": { + "text": "The steps of what you are working on. Click one to go back to it.", + "docs": "editor/overview" + }, "openByDefault": false, "title": "History", "icon": "history", diff --git a/plugins/inspectors/nanoforge.manifest.json b/plugins/inspectors/nanoforge.manifest.json index b2e10f64..8733594c 100644 --- a/plugins/inspectors/nanoforge.manifest.json +++ b/plugins/inspectors/nanoforge.manifest.json @@ -16,6 +16,10 @@ "widgets": [ { "id": "inspectors.profiler", + "tip": { + "text": "While the game plays: tick time, and the time per library and per system. Click the chart to freeze it.", + "docs": "editor/play" + }, "title": "Profiler", "icon": "activity", "defaultSlot": "bottom", @@ -23,6 +27,10 @@ }, { "id": "inspectors.network", + "tip": { + "text": "While the game plays: the packets it sends and receives, with their size and content.", + "docs": "editor/play" + }, "title": "Network", "icon": "network", "defaultSlot": "bottom", @@ -30,6 +38,10 @@ }, { "id": "inspectors.world", + "tip": { + "text": "While the game plays: every entity with its components, live. Search with Position or Position.x > 100.", + "docs": "editor/play" + }, "title": "World", "icon": "globe", "defaultSlot": "bottom", diff --git a/plugins/scene/nanoforge.manifest.json b/plugins/scene/nanoforge.manifest.json index c62c4f27..2bff7572 100644 --- a/plugins/scene/nanoforge.manifest.json +++ b/plugins/scene/nanoforge.manifest.json @@ -30,6 +30,10 @@ "widgets": [ { "id": "scene.scenes", + "tip": { + "text": "The scenes of the app. Select one to edit its entities and systems in the Hierarchy, Inspector and Scene screen.", + "docs": "editor/scenes" + }, "essential": true, "title": "Scenes", "icon": "layers", @@ -38,6 +42,10 @@ }, { "id": "scene.vars", + "tip": { + "text": "The vars that scenes and systems share, and their live values while the game plays.", + "docs": "editor/scenes" + }, "title": "Vars", "icon": "variable", "defaultSlot": "rightBottom", diff --git a/turbo.json b/turbo.json index 468f982f..8e25027e 100644 --- a/turbo.json +++ b/turbo.json @@ -16,6 +16,7 @@ "static/**", "server/**", "scripts/**", + "tutorial/**", "package.json", "tsconfig.json", "*.config.{js,ts}",