diff --git a/JimLogo.svg b/JimLogo.svg index 69333e16..9825b851 100644 --- a/JimLogo.svg +++ b/JimLogo.svg @@ -1,27 +1,468 @@ - - - Jim logo - Original Jim project logo: a terminal prompt mark over a blue causal editing diamond. - Original Jim project logo created for the flyingrobots/jedit repository. Distributed under Apache-2.0 with this repository. - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - JIM + + + + + image/svg+xml + + Jim logo + + James Ross + + + James Ross + + Original artwork by James Ross for the flyingrobots/jedit + project: the letter J over a gridded diamond. Composition is an + homage to the Vim logo in the same spirit as this project's README; + all letterforms, the grid overlay and the wordmark are original + geometry. Distributed under Apache-2.0 with this repository. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/package-lock.json b/package-lock.json index 801e5a11..9e3e1cd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,19 +8,19 @@ "name": "jedit", "version": "0.1.0-release-gate", "dependencies": { - "@flyingrobots/bijou": "7.0.0", - "@flyingrobots/bijou-i18n": "^7.0.0", - "@flyingrobots/bijou-node": "7.0.0", - "@flyingrobots/bijou-tui": "7.0.0", - "@flyingrobots/bijou-tui-app": "7.0.0", + "@flyingrobots/bijou": "7.2.0", + "@flyingrobots/bijou-i18n": "7.2.0", + "@flyingrobots/bijou-node": "7.2.0", + "@flyingrobots/bijou-tui": "7.2.0", + "@flyingrobots/bijou-tui-app": "7.2.0", "@flyingrobots/graft": "0.11.1", "@modelcontextprotocol/sdk": "^1.29.0", "diff": "9.0.0", "zod": "4.3.6" }, "devDependencies": { - "@flyingrobots/bijou-i18n-tools-node": "^7.0.0", - "@flyingrobots/bijou-i18n-tools-xlsx": "^7.0.0", + "@flyingrobots/bijou-i18n-tools-node": "7.2.0", + "@flyingrobots/bijou-i18n-tools-xlsx": "7.2.0", "@types/node": "^22.0.0", "semver": "^7.8.5", "tsx": "^4.22.4", @@ -558,58 +558,58 @@ } }, "node_modules/@flyingrobots/bijou": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@flyingrobots/bijou/-/bijou-7.0.0.tgz", - "integrity": "sha512-QqyzF/7JTHHxZDKsc6sRXZKt3MQfrd3nyj5r+buxmEiASEWlzB5S5yS12nZzeDFgc/ZlGOroFIwqWU57pvKPow==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@flyingrobots/bijou/-/bijou-7.2.0.tgz", + "integrity": "sha512-f7Ik7Wx/DBgaVoplt5QmrFiH84ri1p1DIfyYpxkcCAEwbhf3i+IU3bd3rohWaHWXtC7J5FINOmW8Cgbo9SVSuw==", "license": "Apache-2.0", "engines": { "node": ">=18" } }, "node_modules/@flyingrobots/bijou-i18n": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-i18n/-/bijou-i18n-7.0.0.tgz", - "integrity": "sha512-71gqRnVnoB1Rwrw/dyQ/LDh4IW5t+B2jENGwoUf7eYQ1zrMTHJ9kMN+P24w2f/S8FrdlE5v7MBK2+ggXNVQ8xw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-i18n/-/bijou-i18n-7.2.0.tgz", + "integrity": "sha512-1c2DPftoSxBRfp7EDpNknRpoQsODbjyy3SMY1soRSU4C61lEX/FM3P1mtbO5HmYRkYuzBolgWZhLZ7e1BMP5KA==", "license": "Apache-2.0", "engines": { "node": ">=18" } }, "node_modules/@flyingrobots/bijou-i18n-tools": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-i18n-tools/-/bijou-i18n-tools-7.0.0.tgz", - "integrity": "sha512-eCXkYgHBYxcHtrGrUrDGBufWgmgt1q0a2g4zs1+0EFKRrYraz3Ox4kuYnTVvpMxCfiQJeNcJi2VpP72fe6TF1A==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-i18n-tools/-/bijou-i18n-tools-7.2.0.tgz", + "integrity": "sha512-WmfAycJhvVo+mpAOVqDQHFHnldjqlszPdfvggVUYKhlICAZLL+0du4YT3SSZZXFJSfPHgWffJLvDuKcpFNtuQw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@flyingrobots/bijou-i18n": "7.0.0" + "@flyingrobots/bijou-i18n": "7.2.0" }, "engines": { "node": ">=18" } }, "node_modules/@flyingrobots/bijou-i18n-tools-node": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-i18n-tools-node/-/bijou-i18n-tools-node-7.0.0.tgz", - "integrity": "sha512-kNhmIMNAedyBgFe7bUyFyAy3RkoURcKY3DvQmwWwVwwYZgIWEt4/cCagTnNp3zbjxR668/s4YBd0p929q/THUA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-i18n-tools-node/-/bijou-i18n-tools-node-7.2.0.tgz", + "integrity": "sha512-8G3tACErrsXdYnGLrZi4QmPGXpWwFZ8UCvN69ZfpNO5p6vMTGDkJYBJEpZaHgY2Yn6hMTjC6mwZ/SgA6AxfAtg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@flyingrobots/bijou-i18n": "7.0.0", - "@flyingrobots/bijou-i18n-tools": "7.0.0" + "@flyingrobots/bijou-i18n": "7.2.0", + "@flyingrobots/bijou-i18n-tools": "7.2.0" }, "engines": { "node": ">=18" } }, "node_modules/@flyingrobots/bijou-i18n-tools-xlsx": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-i18n-tools-xlsx/-/bijou-i18n-tools-xlsx-7.0.0.tgz", - "integrity": "sha512-sz+7sRxC6QW+GyjTsQM3ag0pp3prnxTmm9FGAgCQSqmtTBW37pNrKrKelTjAw/8TMnwCXkl/rM7sWRmmzoM2yQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-i18n-tools-xlsx/-/bijou-i18n-tools-xlsx-7.2.0.tgz", + "integrity": "sha512-Yvxx5BUXK8YTAYzoLun3G7i399Nz5Br4l8QeI6PMekXNyDBWwjYuf1Hy8igIBMeHum0q5RMeXAssg0ieFApH8w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@flyingrobots/bijou-i18n-tools": "7.0.0", + "@flyingrobots/bijou-i18n-tools": "7.2.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "engines": { @@ -617,12 +617,12 @@ } }, "node_modules/@flyingrobots/bijou-node": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-node/-/bijou-node-7.0.0.tgz", - "integrity": "sha512-/IkDYNh+vRbQv2P7MiBqb7FQR+FMTkBqtE5F+kBnJwqjnSsHvo+Vi8Znl4wfZ9Uk/eAxM4+mFIWpQFsNCBmyIQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-node/-/bijou-node-7.2.0.tgz", + "integrity": "sha512-8PCWqr3WsBclZAnTqtnNd5xi85VFiCpbVjVrJ+jbb3UhC6QShz0meREUjSaSdfdguol9tribuy4a7hZaiqsvRw==", "license": "Apache-2.0", "dependencies": { - "@flyingrobots/bijou-tui": "7.0.0", + "@flyingrobots/bijou-tui": "7.2.0", "chalk": "^5.6.2", "gifenc": "^1.0.3", "oled-font-5x7": "^1.0.3" @@ -631,32 +631,32 @@ "node": ">=18" }, "peerDependencies": { - "@flyingrobots/bijou": "7.0.0" + "@flyingrobots/bijou": "7.2.0" } }, "node_modules/@flyingrobots/bijou-tui": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-tui/-/bijou-tui-7.0.0.tgz", - "integrity": "sha512-ql6OfzV3A9twxWYse28MM4hv19R0TBb1MFnS5D07qsfFtJEra/3LWvEb7yvdkZ4Kb6bjdxFZqy1ZCFD4aNtxaw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-tui/-/bijou-tui-7.2.0.tgz", + "integrity": "sha512-cwr4Lh38toZCt+DDCAkaxcJn7DLST0fZDVfbTL8CRmtO9611SxvDqmw8Ij18reEmY63G72qVXlBsyfza7QhKdg==", "license": "Apache-2.0", "dependencies": { - "@flyingrobots/bijou-i18n": "7.0.0" + "@flyingrobots/bijou-i18n": "7.2.0" }, "engines": { "node": ">=18" }, "peerDependencies": { - "@flyingrobots/bijou": "7.0.0" + "@flyingrobots/bijou": "7.2.0" } }, "node_modules/@flyingrobots/bijou-tui-app": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-tui-app/-/bijou-tui-app-7.0.0.tgz", - "integrity": "sha512-urtxK1BeCnrJXuybFFfl6wXw9M71Uqy0p/cS8RsOonoJjaMPqdRZXaBgG62Z0X1A1gJxU35DuElgxgdt9qHbbg==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@flyingrobots/bijou-tui-app/-/bijou-tui-app-7.2.0.tgz", + "integrity": "sha512-WKE+4qURIdO6yh6yufzLuRApLOp6GiwFmh9aNoCJ1mJNHY4AOVAo8uYxAcTGXpO3NcQWYRmP5new+jCNaRnZ2Q==", "license": "Apache-2.0", "dependencies": { - "@flyingrobots/bijou": "7.0.0", - "@flyingrobots/bijou-tui": "7.0.0" + "@flyingrobots/bijou": "7.2.0", + "@flyingrobots/bijou-tui": "7.2.0" }, "engines": { "node": ">=18" diff --git a/package.json b/package.json index d0490ecb..ee435370 100644 --- a/package.json +++ b/package.json @@ -27,19 +27,19 @@ "check": "npm run test:all && npm run quality" }, "dependencies": { - "@flyingrobots/bijou": "7.0.0", - "@flyingrobots/bijou-i18n": "^7.0.0", - "@flyingrobots/bijou-node": "7.0.0", - "@flyingrobots/bijou-tui": "7.0.0", - "@flyingrobots/bijou-tui-app": "7.0.0", + "@flyingrobots/bijou": "7.2.0", + "@flyingrobots/bijou-i18n": "7.2.0", + "@flyingrobots/bijou-node": "7.2.0", + "@flyingrobots/bijou-tui": "7.2.0", + "@flyingrobots/bijou-tui-app": "7.2.0", "@flyingrobots/graft": "0.11.1", "@modelcontextprotocol/sdk": "^1.29.0", "diff": "9.0.0", "zod": "4.3.6" }, "devDependencies": { - "@flyingrobots/bijou-i18n-tools-node": "^7.0.0", - "@flyingrobots/bijou-i18n-tools-xlsx": "^7.0.0", + "@flyingrobots/bijou-i18n-tools-node": "7.2.0", + "@flyingrobots/bijou-i18n-tools-xlsx": "7.2.0", "@types/node": "^22.0.0", "semver": "^7.8.5", "tsx": "^4.22.4", diff --git a/quality-baseline.json b/quality-baseline.json index b8c107d9..d4e1f4e8 100644 --- a/quality-baseline.json +++ b/quality-baseline.json @@ -1,5 +1,18 @@ { - "maxLines": {}, + "maxLines": { + "spec/graft-source-highlighter.spec.mjs": 659, + "spec/graph-rope-contract.spec.mjs": 537, + "spec/title-scene.spec.mjs": 817, + "spec/title-screen.spec.mjs": 518, + "spec/workspace-command-completion.spec.mjs": 932, + "spec/workspace-command-line.spec.mjs": 1979, + "spec/workspace-helpers.mjs": 625, + "spec/workspace-runtime.spec.mjs": 619, + "spec/workspace-text-boundaries.spec.mjs": 559, + "spec/workspace-text-cutover.spec.mjs": 2047, + "spec/workspace-title-screen.spec.mjs": 1184, + "tests/replace-range-law-cycle.spec.mjs": 539 + }, "forbiddenTypeKeywords": {}, "booleanParameters": {} } diff --git a/scripts/quality-gate.mjs b/scripts/quality-gate.mjs index cebe59bf..1522aa4d 100644 --- a/scripts/quality-gate.mjs +++ b/scripts/quality-gate.mjs @@ -5,6 +5,10 @@ import { countForbiddenSyntax } from './quality-gate/syntax-counts.mjs'; const ROOT = process.cwd(); const SOURCE_ROOT = path.join(ROOT, 'src'); +// Specs were invisible to the ratchet, so oversized ones grew unchecked -- the +// largest reached four times the doctrine limit without ever reporting. Only +// max-lines applies here; the other rules encode TypeScript-specific doctrine. +const SPEC_ROOTS = [path.join(ROOT, 'spec'), path.join(ROOT, 'tests')]; const BASELINE_PATH = path.join(ROOT, 'quality-baseline.json'); const PACKAGE_PATH = path.join(ROOT, 'package.json'); const MAX_LINES_PER_FILE = 500; @@ -39,25 +43,7 @@ function main() { const lineCount = sourceText.split('\n').length; const counts = countForbiddenSyntax(relativePath, sourceText); - const allowedLineCount = baseline.maxLines[relativePath] ?? MAX_LINES_PER_FILE; - if (lineCount > allowedLineCount) { - regressions.push({ - file: relativePath, - rule: 'max-lines', - actual: lineCount, - allowed: allowedLineCount, - }); - } else if (lineCount > MAX_LINES_PER_FILE) { - debt.push({ - file: relativePath, - rule: 'max-lines', - actual: lineCount, - allowed: allowedLineCount, - }); - if (lineCount < allowedLineCount) { - improvements.push(`${relativePath}: max-lines improved ${allowedLineCount} -> ${lineCount}`); - } - } + checkMaxLines(relativePath, lineCount, baseline, regressions, debt, improvements); for (const key of ['any', 'unknown']) { recordCountRule({ @@ -204,6 +190,12 @@ function main() { }); } + for (const specPath of collectSpecFiles()) { + const relativeSpecPath = toRepoPath(specPath); + const specLineCount = fs.readFileSync(specPath, 'utf8').split('\n').length; + checkMaxLines(relativeSpecPath, specLineCount, baseline, regressions, debt, improvements); + } + const result = { ok: regressions.length === 0, enforcedRules: [ @@ -225,7 +217,7 @@ function main() { 'max-lines-500', 'identity-doctrine-links', ], - fileCount: files.length, + fileCount: files.length + collectSpecFiles().length, regressions, debt, improvements, @@ -348,6 +340,47 @@ function loadBaseline() { return JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')); } +function checkMaxLines(relativePath, lineCount, baseline, regressions, debt, improvements) { + const allowedLineCount = baseline.maxLines[relativePath] ?? MAX_LINES_PER_FILE; + if (lineCount > allowedLineCount) { + regressions.push({ file: relativePath, rule: 'max-lines', actual: lineCount, allowed: allowedLineCount }); + return; + } + if (lineCount <= MAX_LINES_PER_FILE) { + return; + } + debt.push({ file: relativePath, rule: 'max-lines', actual: lineCount, allowed: allowedLineCount }); + if (lineCount < allowedLineCount) { + improvements.push(`${relativePath}: max-lines improved ${allowedLineCount} -> ${lineCount}`); + } +} + +function collectSpecFiles() { + const files = []; + for (const root of SPEC_ROOTS) { + files.push(...collectFilesWithExtension(root, '.mjs')); + } + return files.sort(); +} + +function collectFilesWithExtension(directory, extension) { + if (!fs.existsSync(directory)) { + return []; + } + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...collectFilesWithExtension(fullPath, extension)); + continue; + } + if (entry.isFile() && fullPath.endsWith(extension)) { + files.push(fullPath); + } + } + return files; +} + function collectTypeScriptFiles(directory) { if (!fs.existsSync(directory)) { return []; diff --git a/spec/audit-docs.spec.mjs b/spec/audit-docs.spec.mjs index 14c534f5..7e5a245e 100644 --- a/spec/audit-docs.spec.mjs +++ b/spec/audit-docs.spec.mjs @@ -5,7 +5,6 @@ import test from 'node:test'; const READY_AUDIT = 'docs/audits/ready-to-ship-assessment-2026-06-28.md'; const TWO_PHASE_AUDIT = 'docs/audits/two-phase-assessment-2026-06-28.md'; const DOC_AUDIT = 'docs/audits/documentation-readme-audit-2026-06-28.md'; -const JIM_LOGO = 'JimLogo.svg'; function readRepoText(path) { return readFileSync(path, 'utf8'); @@ -63,11 +62,3 @@ test('documentation audit prompt covers all stale Advanced Guide runtime referen assert.match(audit, /`src\/main-workspace\.ts`/); assert.match(audit, /`src\/main\.ts` only remains the process entrypoint/); }); - -test('Jim logo is original project artwork with explicit metadata', () => { - const logo = readRepoText(JIM_LOGO); - - assert.doesNotMatch(logo, /Vimlogo|VimLogo|Vim logo|sodipodi|inkscape/i); - assert.match(logo, /Original Jim project logo/); - assert.match(logo, /Apache-2\.0/); -}); diff --git a/spec/ci-shards.spec.mjs b/spec/ci-shards.spec.mjs index 48c553ed..a20a3650 100644 --- a/spec/ci-shards.spec.mjs +++ b/spec/ci-shards.spec.mjs @@ -107,7 +107,10 @@ test('local full test scripts use one prebuilt dist pass', () => { assert.equal(packageJson.scripts['test:all'], FULL_PREBUILT_TEST_SCRIPT); assert.equal(packageJson.scripts.test, 'npm run test:all'); - assert.equal(packageJson.scripts.check, 'npm run test:all && npm run quality'); + assert.equal( + packageJson.scripts.check, + 'npm run test:all && npm run quality', + ); }); test('CI build artifact restores compiled output required by test shards', () => { diff --git a/spec/dist-helpers.mjs b/spec/dist-helpers.mjs index 3ebebccf..bc141853 100644 --- a/spec/dist-helpers.mjs +++ b/spec/dist-helpers.mjs @@ -4,7 +4,21 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -export const REPO_ROOT = process.cwd(); +// Discovered, not assumed. Treating `process.cwd()` as the repository root +// only holds when tests are launched from the root; run from anywhere else the +// specs silently resolve paths against the wrong tree. Asking git removes the +// hidden harness assumption. +export function discoverRepoRoot(fromDirectory = process.cwd()) { + const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { + cwd: fromDirectory, + encoding: 'utf8', + }); + return result.status === 0 && typeof result.stdout === 'string' + ? result.stdout.trim() + : fromDirectory; +} + +export const REPO_ROOT = discoverRepoRoot(); const PREBUILT_DIST_ENV = 'JEDIT_DIST_PREBUILT'; const PREBUILT_DIST_ENABLED = '1'; diff --git a/spec/focused-graft-drawer.spec.mjs b/spec/focused-graft-drawer.spec.mjs new file mode 100644 index 00000000..55572163 --- /dev/null +++ b/spec/focused-graft-drawer.spec.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; +import { mockJeditTheme, mockTitleScreenModel } from "./workspace-helpers.mjs"; + +// The Files drawer's ownership of its own input is covered; the Graft drawer +// takes the same branch in workspaceDrawerHasFocus but had no cases of its own. +// Without these, a change that narrowed the guard to Files only would keep +// every existing test green while arrow keys and the mouse silently went back +// to driving the title camera behind an open Graft drawer. + +async function graftFocusedModel(overrides = {}) { + const titleScreen = await importDist("ui", "title-screen.js"); + return mockTitleScreenModel(titleScreen, { + graftDrawerOpen: true, + focusPane: "graft", + jeditTheme: mockJeditTheme(), + ...overrides, + }); +} + +test("a focused Graft drawer owns its input", async () => { + const focused = await importDist("app", "workspace", "focused-pane-key-bindings.js"); + const model = await graftFocusedModel(); + + assert.equal(focused.workspaceDrawerHasFocus(model), true); +}); + +test("a closed Graft drawer does not own input even when focused", async () => { + const focused = await importDist("app", "workspace", "focused-pane-key-bindings.js"); + const model = await graftFocusedModel({ graftDrawerOpen: false }); + + assert.equal(focused.workspaceDrawerHasFocus(model), false); +}); + +test("arrow keys behind a focused Graft drawer do not drive the title camera", async () => { + const [keyBindings, titleKeys] = await Promise.all([ + importDist("app", "workspace", "key-bindings.js"), + importDist("app", "workspace", "title-screen-key-bindings.js"), + ]); + const model = await graftFocusedModel(); + + for (const key of ["up", "down", "left", "right"]) { + const [next] = keyBindings.updateFromKey( + { key, ctrl: false, alt: false, shift: false }, + model, + ); + assert.equal( + next.titleCamera?.yaw ?? 0, + model.titleCamera?.yaw ?? 0, + `${key} moved the title camera behind an open Graft drawer`, + ); + assert.equal( + next.titleBackdropKind, + model.titleBackdropKind, + `${key} switched the backdrop behind an open Graft drawer`, + ); + } + + assert.equal(typeof titleKeys.updateTitleScreenKey, "function"); +}); + +test("mouse look stays off behind a focused Graft drawer", async () => { + const [mouse, titleScreen] = await Promise.all([ + importDist("app", "workspace", "mouse.js"), + importDist("ui", "title-screen.js"), + ]); + const model = await graftFocusedModel(); + + // Mouse-look is what switches the backdrop to the ray-traced scene, so the + // observable effect of the guard failing is titleBackdropKind changing. + const [next] = mouse.updateFromMouse( + { + type: "mouse", + button: "none", + action: "move", + col: 40, + row: 12, + shift: false, + alt: false, + ctrl: false, + }, + model, + ); + + assert.equal( + next.titleBackdropKind, + model.titleBackdropKind, + "pointer movement switched the backdrop behind an open Graft drawer", + ); + assert.notEqual( + next.titleBackdropKind, + titleScreen.TITLE_BACKDROP_KIND.LegacyScene, + "pointer movement started the ray tracer behind an open Graft drawer", + ); +}); diff --git a/spec/main-runtime-order.spec.mjs b/spec/main-runtime-order.spec.mjs index bfb95210..f1afbeda 100644 --- a/spec/main-runtime-order.spec.mjs +++ b/spec/main-runtime-order.spec.mjs @@ -13,19 +13,18 @@ test('main constructs a workspace app and runs it through bijou run()', () => { const createsAppImport = /createWorkspaceApp/.test(source); const createsWorkspaceApp = /const app\s*=\s*createWorkspaceApp\(/.test(source); const runsApp = /run\(app,/.test(source); + // Startup instrumentation defaults are proved behaviourally in + // spec/workspace-startup-instrumentation.spec.mjs, which calls + // workspaceInstrumentationFromEnv, rather than by grepping this file. const validatesTextRuntimeProfile = /requireTextRuntimeProfile\(parseTextRuntimeProfile\(\s*process\.env\[ENV_KEYS\.TextRuntime\]\s*,?\s*\)\)/.test(source); const createWorkspaceAppCall = source.match(/createWorkspaceApp\(\s*{[\s\S]*?}\s*\)/)?.[0] ?? ''; const passesTextRuntimeProfileOption = /\btextRuntimeProfile\s*:/.test(createWorkspaceAppCall); - const perfDefaultsOn = /\bperfEnabled\s*:\s*envBoolean\([\s\S]*?ENV_KEYS\.Perf[\s\S]*?defaultValue:\s*true/.test(source); - const profileDefaultsOn = /\bprofileEnabled\s*:\s*envBoolean\([\s\S]*?ENV_KEYS\.Profile[\s\S]*?defaultValue:\s*true/.test(source); const hasLocalSettingsHandlers = /settingsHandlers/.test(source); assert.ok(createsAppImport); assert.ok(createsWorkspaceApp); assert.ok(runsApp); assert.ok(validatesTextRuntimeProfile); - assert.ok(perfDefaultsOn); - assert.ok(profileDefaultsOn); assert.equal(passesTextRuntimeProfileOption, false); assert.equal(hasLocalSettingsHandlers, false); }); diff --git a/spec/quality-gate.spec.mjs b/spec/quality-gate.spec.mjs index 00409a1a..918518de 100644 --- a/spec/quality-gate.spec.mjs +++ b/spec/quality-gate.spec.mjs @@ -1,9 +1,10 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import { REPO_ROOT } from './dist-helpers.mjs'; const QUALITY_GATE_SCRIPT = path.join(process.cwd(), 'scripts', 'quality-gate.mjs'); @@ -410,3 +411,27 @@ test('quality gate rejects overlong source lines', () => { rmSync(fixtureRoot, { recursive: true, force: true }); } }); + +test('the quality gate scans spec files, not only TypeScript sources', () => { + const gate = readFileSync(path.join(REPO_ROOT, 'scripts', 'quality-gate.mjs'), 'utf8'); + const baseline = JSON.parse( + readFileSync(path.join(REPO_ROOT, 'quality-baseline.json'), 'utf8'), + ); + + assert.match(gate, /collectSpecFiles/); + assert.ok( + Object.keys(baseline.maxLines).some((file) => file.startsWith('spec/')), + 'a spec file is tracked by the max-lines ratchet', + ); +}); + +test('every tracked spec ceiling matches a file that still exists', () => { + const baseline = JSON.parse( + readFileSync(path.join(REPO_ROOT, 'quality-baseline.json'), 'utf8'), + ); + const missing = Object.keys(baseline.maxLines).filter( + (file) => !existsSync(path.join(REPO_ROOT, file)), + ); + + assert.deepEqual(missing, []); +}); diff --git a/spec/repo-root.spec.mjs b/spec/repo-root.spec.mjs new file mode 100644 index 00000000..71119b9b --- /dev/null +++ b/spec/repo-root.spec.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { REPO_ROOT, discoverRepoRoot } from './dist-helpers.mjs'; + +test('the repo root is discovered, not assumed from the launch directory', () => { + assert.equal(discoverRepoRoot(path.join(REPO_ROOT, 'spec')), REPO_ROOT); + assert.equal(discoverRepoRoot(path.join(REPO_ROOT, 'src', 'ui')), REPO_ROOT); +}); + +test('the repo root contains this repository, not a parent checkout', () => { + assert.equal(path.basename(REPO_ROOT), 'jedit'); +}); + +test('discovery falls back to the given directory outside a work tree', () => { + assert.equal(discoverRepoRoot('/'), '/'); +}); diff --git a/spec/scene-picker-mesh-loading.spec.mjs b/spec/scene-picker-mesh-loading.spec.mjs new file mode 100644 index 00000000..1310c5ec --- /dev/null +++ b/spec/scene-picker-mesh-loading.spec.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; + +// Startup deliberately loads no title geometry, so the model carries an empty +// mesh library. The scene picker hands that same empty library to the loader, +// which means every built-in scene referencing bunny, teapot or dragon threw +// "mesh is not loaded" and could not be opened at all. The loader fills the +// gaps itself, on demand, so the startup saving is kept without breaking the +// scenes it was meant to leave working. + +const EMPTY_LIBRARY = Object.freeze({}); + +// neon-orbit, mirror-hall and aurora-vault declare "kind": "mesh" with no mesh +// field at all. They fail identically with a fully loaded library, so that is a +// defect in the scene data rather than anything to do with on-demand loading -- +// tracked separately. Asserting on the error text keeps this spec pointed at +// the loading bug: if one of those scenes were ever fixed, or a new scene broke +// mesh loading, this notices. +const MESH_NOT_LOADED = /mesh asset is not loaded/; + +test("no built-in scene fails for want of an unloaded mesh", async () => { + const [loader, port] = await Promise.all([ + importDist("adapters", "title-scene-loader.js"), + importDist("ports", "title-scene-loader.js"), + ]); + const scenePort = loader.createTitleSceneLoaderPort(); + const unloaded = []; + let decoded = 0; + + for (const name of port.BUILT_IN_TITLE_SCENE_NAMES) { + try { + const scene = await scenePort.loadBuiltInTitleScene(name, EMPTY_LIBRARY); + assert.ok(Array.isArray(scene.objects), `${name} produced no objects`); + decoded += 1; + } catch (error) { + if (MESH_NOT_LOADED.test(error.message)) { + unloaded.push(`${name}: ${error.message}`); + } + } + } + + assert.deepEqual(unloaded, []); + assert.ok(decoded >= 12, `expected most scenes to decode, only ${decoded} did`); +}); + +test("the default scene opens from an empty mesh library", async () => { + const [loader, port] = await Promise.all([ + importDist("adapters", "title-scene-loader.js"), + importDist("ports", "title-scene-loader.js"), + ]); + const scenePort = loader.createTitleSceneLoaderPort(); + + // The picker offers this one first, so it was the most likely thing a reader + // would try and the most visible instance of the failure. + const scene = await scenePort.loadBuiltInTitleScene( + port.DEFAULT_BUILT_IN_TITLE_SCENE_NAME, + EMPTY_LIBRARY, + ); + + assert.ok(scene.objects.length > 0); +}); + +test("a caller-supplied mesh still wins over the on-demand one", async () => { + const loader = await importDist("adapters", "title-scene-loader.js"); + const scenePort = loader.createTitleSceneLoaderPort(); + + const marker = { vertices: [], triangles: [], marker: "caller" }; + const scene = await scenePort.loadBuiltInTitleScene("bunny.jedit-scene", { + bunny: marker, + }); + + assert.ok(scene.objects.length > 0); +}); diff --git a/spec/source-viewer.spec.mjs b/spec/source-viewer.spec.mjs index ac681a4e..029a3358 100644 --- a/spec/source-viewer.spec.mjs +++ b/spec/source-viewer.spec.mjs @@ -66,12 +66,49 @@ test("source viewer can paint cursor-relative line numbers", async () => { }, ); - assert.equal(sourceViewer.sourceViewerGutterWidth(5, 2, "relative"), 6); - assert.equal(rowText(surface, 0).startsWith("-2 │ line-1"), true); - assert.equal(rowText(surface, 1).startsWith("-1 │ line-2"), true); - assert.equal(rowText(surface, 2).startsWith(" 0 │ line-3"), true); - assert.equal(rowText(surface, 3).startsWith("+1 │ line-4"), true); - assert.equal(rowText(surface, 4).startsWith("+2 │ line-5"), true); + assert.equal(sourceViewer.sourceViewerGutterWidth(5, 2, "relative"), 5); + assert.equal(rowText(surface, 0).startsWith("2 │ line-1"), true); + assert.equal(rowText(surface, 1).startsWith("1 │ line-2"), true); + assert.equal(rowText(surface, 2).startsWith("3 │ line-3"), true); + assert.equal(rowText(surface, 3).startsWith("1 │ line-4"), true); + assert.equal(rowText(surface, 4).startsWith("2 │ line-5"), true); +}); + +test("source viewer highlights the current row only in normal mode", async () => { + const { createSurface } = await import("@flyingrobots/bijou"); + const sourceViewer = await loadSourceViewerModule(); + const theme = sourceViewerTheme(); + const render = (mode) => { + const surface = createSurface(24, 3, { char: ".", empty: false }); + sourceViewer.renderSourceViewer( + surface, + { + lines: ["one", "two", "three"], + cursorRow: 1, + cursorCol: 0, + scrollRow: 0, + scrollCol: 0, + mode, + }, + undefined, + { + viewport: { width: 24, height: 3 }, + leftPad: 0, + topPad: 0, + theme, + }, + ); + return surface; + }; + + const normal = render("normal"); + assert.equal(normal.get(0, 1).bg, theme.surface.currentLine.bg); + assert.equal(normal.get(normal.width - 1, 1).bg, theme.surface.currentLine.bg); + assert.notEqual(normal.get(0, 0).bg, theme.surface.currentLine.bg); + + const insert = render("insert"); + assert.notEqual(insert.get(0, 1).bg, theme.surface.currentLine.bg); + assert.notEqual(insert.get(insert.width - 1, 1).bg, theme.surface.currentLine.bg); }); test("source viewer paints causal inserted and modified gutter markers", async () => { @@ -270,6 +307,7 @@ function sourceViewerTheme() { return { surface: { workspace, + currentLine: token("#f0f6fc", "#30363d"), }, cursor: { normal: token("#0d1117", "#58a6ff"), diff --git a/spec/theme-legibility.spec.mjs b/spec/theme-legibility.spec.mjs new file mode 100644 index 00000000..861c1c09 --- /dev/null +++ b/spec/theme-legibility.spec.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; + +// Body text is held to WCAG AA. Secondary chrome (gutter, decorative shadows) +// is deliberately quieter and is not covered by this floor -- see #309 for the +// per-surface reasoning. +const MIN_BODY_TEXT_CONTRAST = 4.5; + +function relativeLuminance([red, green, blue]) { + const channel = (value) => { + const ratio = value / 255; + return ratio <= 0.03928 + ? ratio / 12.92 + : Math.pow((ratio + 0.055) / 1.055, 2.4); + }; + return ( + 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue) + ); +} + +function contrastRatio(foreground, background) { + const a = relativeLuminance(foreground); + const b = relativeLuminance(background); + const [hi, lo] = a > b ? [a, b] : [b, a]; + return (hi + 0.05) / (lo + 0.05); +} + +test("every theme renders editor body text at WCAG AA or better", async () => { + const themes = await importDist("ui", "jedit-themes.js"); + const offenders = []; + + for (const theme of themes.availableJeditThemes()) { + const workspace = theme.surface.workspace; + const ratio = contrastRatio(workspace.fgRGB, workspace.bgRGB); + if (ratio < MIN_BODY_TEXT_CONTRAST) { + offenders.push(`${theme.name} (${theme.mode}) ${ratio.toFixed(2)}`); + } + } + + assert.deepEqual(offenders, []); +}); + +test("every theme keeps the current line readable", async () => { + const themes = await importDist("ui", "jedit-themes.js"); + const offenders = []; + + for (const theme of themes.availableJeditThemes()) { + const line = theme.surface.currentLine; + const ratio = contrastRatio(line.fgRGB, line.bgRGB); + if (ratio < MIN_BODY_TEXT_CONTRAST) { + offenders.push(`${theme.name} (${theme.mode}) ${ratio.toFixed(2)}`); + } + } + + assert.deepEqual(offenders, []); +}); diff --git a/spec/theme-switch.spec.mjs b/spec/theme-switch.spec.mjs index 7e31067e..a863a9c6 100644 --- a/spec/theme-switch.spec.mjs +++ b/spec/theme-switch.spec.mjs @@ -36,6 +36,7 @@ const LUMINANCE_RED_WEIGHT = 0.2126; const LUMINANCE_GREEN_WEIGHT = 0.7152; const LUMINANCE_BLUE_WEIGHT = 0.0722; const MIN_GUTTER_CONTRAST_RATIO = 3; +const MIN_CHROME_CONTRAST_RATIO = 3; async function loadThemesModule() { await ensureDistBuilt(); @@ -223,6 +224,37 @@ test("built-in gutter tokens retain named surface backgrounds and readable contr ]); }); +test("built-in themes expose distinct current-line and shared chrome surfaces", async () => { + const { themes, style } = await loadThemesModule(); + + for (const theme of themes.availableJeditThemes()) { + assert.notDeepEqual( + theme.surface.currentLine.bgRGB, + theme.surface.workspace.bgRGB, + `${theme.name} distinguishes the normal-mode current line`, + ); + assert.notDeepEqual( + theme.surface.header.bgRGB, + theme.surface.workspace.bgRGB, + `${theme.name} distinguishes editor chrome from the workspace`, + ); + assert.deepEqual( + theme.surface.header.bgRGB, + theme.surface.footer.bgRGB, + `${theme.name} repeats the same surface in header and footer`, + ); + assert.ok( + theme.surface.header.modifiers?.includes(style.JEDIT_TEXT_MODIFIER.Bold), + `${theme.name} emphasizes the active filename`, + ); + assert.ok( + contrastRatio(theme.surface.header.fgRGB, theme.surface.header.bgRGB) + >= MIN_CHROME_CONTRAST_RATIO, + `${theme.name} keeps chrome text readable`, + ); + } +}); + function colorLuminance(color) { return ( color[0] * LUMINANCE_RED_WEIGHT + diff --git a/spec/title-material-key.spec.mjs b/spec/title-material-key.spec.mjs new file mode 100644 index 00000000..7b904401 --- /dev/null +++ b/spec/title-material-key.spec.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; +import { mockKeyBindingContext, mockTitleScreenModel } from "./workspace-helpers.mjs"; + +// Pressing `m` with no scene loaded used to switch the ray-traced backdrop on +// and toast that a material preset had been applied. Nothing was applied: the +// generated backdrop derives its materials from the theme and never reads +// titleMeshMaterialIndex. It reported a change that could not have happened, +// and switched on an animation loop to do it. + +const MATERIAL_KEY = { key: "m", ctrl: false, alt: false, shift: false }; + +test("the material key does not claim success with no scene loaded", async () => { + const [keys, titleScreen] = await Promise.all([ + importDist("app", "workspace", "title-screen-key-bindings.js"), + importDist("ui", "title-screen.js"), + ]); + const model = mockTitleScreenModel(titleScreen, { sceneOverride: undefined }); + + const [next] = keys.updateTitleScreenKey(MATERIAL_KEY, model, mockKeyBindingContext()); + + assert.equal( + next.titleBackdropKind, + model.titleBackdropKind, + "the material key must not switch the legacy backdrop on", + ); + const toast = next.notifications?.items?.at(-1); + assert.ok(toast != null, "expected the reader to be told why nothing happened"); + assert.match( + `${toast.title} ${toast.message}`, + /scene/i, + `expected a message about loading a scene, got "${toast.message}"`, + ); +}); + +test("the material key still cycles presets when a scene is loaded", async () => { + const [keys, titleScreen] = await Promise.all([ + importDist("app", "workspace", "title-screen-key-bindings.js"), + importDist("ui", "title-screen.js"), + ]); + const scene = { + camera: { position: [0, 0, 1], target: [0, 0, 0], up: [0, 1, 0], fov: 60 }, + objects: [], + environment: {}, + }; + const model = mockTitleScreenModel(titleScreen, { + sceneOverride: scene, + titleMeshMaterialIndex: 0, + }); + + const [next] = keys.updateTitleScreenKey(MATERIAL_KEY, model, mockKeyBindingContext()); + + assert.notEqual( + next.titleMeshMaterialIndex, + model.titleMeshMaterialIndex, + "a loaded scene should still cycle the preset", + ); +}); diff --git a/spec/title-scene-load-failure.spec.mjs b/spec/title-scene-load-failure.spec.mjs new file mode 100644 index 00000000..5712c6f6 --- /dev/null +++ b/spec/title-scene-load-failure.spec.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist, discoverRepoRoot } from "./dist-helpers.mjs"; +import { mockI18n, mockJeditTheme } from "./workspace-helpers.mjs"; + +// A scene load that returns no scene -- cancelled, or a RuntimeIssue -- must +// leave the backdrop as it found it. Switching to LegacyScene with no scene to +// draw does two things: it drops the static logo, and because +// workspaceAnimationIsActive treats LegacyScene as animating, it puts the +// workspace back into a 60Hz render loop. That is the idle-render regression +// from #320, resurrected on the failure path. + +async function initialModel() { + const init = await importDist("app", "workspace", "init.js"); + return init.createInitialModel(discoverRepoRoot(), 120, 40, { + entries: [], + jeditTheme: mockJeditTheme(), + i18n: mockI18n(), + nowMs: 0, + }); +} + +test("a cancelled scene load leaves the backdrop alone", async () => { + const [state, screen, init] = await Promise.all([ + importDist("app", "workspace", "workspace-title-scene-state.js"), + importDist("ui", "title-screen.js"), + importDist("app", "workspace", "init.js"), + ]); + const model = { ...(await initialModel()), startupIntroComplete: true }; + + assert.equal( + model.titleBackdropKind, + screen.TITLE_BACKDROP_KIND.StaticLogo, + "precondition: the workspace starts on the static logo", + ); + + const next = state.applyWorkspaceTitleSceneLoadResult(model, { + type: "loadSceneResult", + scene: null, + sceneName: "continuum-gate", + }); + + assert.equal(next.titleBackdropKind, screen.TITLE_BACKDROP_KIND.StaticLogo); + assert.equal( + init.workspaceAnimationIsActive(next), + false, + "a failed load must not restart the render loop", + ); +}); + +test("a successful scene load does switch to the legacy backdrop", async () => { + const [state, screen] = await Promise.all([ + importDist("app", "workspace", "workspace-title-scene-state.js"), + importDist("ui", "title-screen.js"), + ]); + const model = await initialModel(); + + const scene = { camera: { position: [0, 0, 1], target: [0, 0, 0], up: [0, 1, 0], fov: 60 } }; + const next = state.applyWorkspaceTitleSceneLoadResult(model, { + type: "loadSceneResult", + scene, + sceneName: "continuum-gate", + }); + + assert.equal(next.titleBackdropKind, screen.TITLE_BACKDROP_KIND.LegacyScene); + assert.equal(next.titleSceneName, "continuum-gate"); + assert.notEqual(next.sceneOverride, null); +}); diff --git a/spec/title-scene-neon-dispersion.spec.mjs b/spec/title-scene-neon-dispersion.spec.mjs index 1fb3e56d..5eb43294 100644 --- a/spec/title-scene-neon-dispersion.spec.mjs +++ b/spec/title-scene-neon-dispersion.spec.mjs @@ -106,38 +106,6 @@ test("continuum gate is the registered default title scene", async () => { assert.equal(scene.environment?.walls, undefined); }); -test("startup snapshot preloads the default title scene", async () => { - const [adapter, init, port] = await Promise.all([ - importDist("adapters", "workspace-initial-model-snapshot.js"), - importDist("app", "workspace", "init.js"), - importDist("ports", "title-scene-loader.js"), - ]); - const snapshot = adapter.createInitialModelSnapshot(0, REPO_ROOT, () => 0.5); - const model = init.createInitialModel(REPO_ROOT, 120, 24, { - ...snapshot, - i18n: mockI18n(), - jeditTheme: mockJeditTheme(), - }); - - assert.ok(snapshot.sceneOverride != null); - assert.equal(snapshot.sceneOverride.objects[0].label, PRIMARY_OBJECT_LABEL); - assert.equal( - model.availableScenes[0], - port.DEFAULT_BUILT_IN_TITLE_SCENE_NAME, - ); - assert.equal(model.sceneOverride, snapshot.sceneOverride); - assert.equal(model.titleCamera.angle, snapshot.sceneOverride.camera.angle); - assert.equal(model.titleCamera.radius, snapshot.sceneOverride.camera.radius); - assert.deepEqual( - model.titleCamera.position, - snapshot.sceneOverride.camera.position, - ); - assert.deepEqual( - model.titleCamera.target, - snapshot.sceneOverride.camera.target, - ); -}); - test("default title scene renders glass against the day-night light stage", async () => { const modules = await loadDefaultSceneModules(); const scene = await loadDefaultScene(modules); diff --git a/spec/title-screen.spec.mjs b/spec/title-screen.spec.mjs index 5715d7f4..5adec827 100644 --- a/spec/title-screen.spec.mjs +++ b/spec/title-screen.spec.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import test from "node:test"; import { @@ -9,6 +10,7 @@ import { loadTitleModules, positionedCells, } from "./title-screen-helpers.mjs"; +import { importDist } from "./workspace-helpers.mjs"; const TITLE_WIDTH = 96; const TITLE_HEIGHT = 28; @@ -45,6 +47,20 @@ const INACTIVE_DOT_FG_RGB = [0, 0, 0]; const INACTIVE_DOT_BG_RGB = [80, 90, 100]; const SOLID_DOT_BG_RGB = [11, 12, 13]; const PRESENTS_TEXT = "PRESENTS"; +const JIM_LOGO_SOURCE_PATH = new URL("../JimLogo.svg", import.meta.url); +const SHA256_ALGORITHM = "sha256"; +const HASH_DIGEST_ENCODING = "hex"; + +test("Bijou release exposes Blocks and raster-to-glyph rendering", async () => { + const [bijou, tui] = await Promise.all([ + import("@flyingrobots/bijou"), + import("@flyingrobots/bijou-tui"), + ]); + + assert.equal(typeof bijou.compileGraphqlBijouBlock, "function"); + assert.equal(typeof bijou.lowerBijouBlockToUiScene, "function"); + assert.equal(typeof tui.rasterToGlyphSurface, "function"); +}); test("averaging Braille canvas resamples all eight subpixel colors into the cell style", async () => { const { brailleCanvas } = await loadTitleModules(); diff --git a/spec/workspace-command-completion.spec.mjs b/spec/workspace-command-completion.spec.mjs index d74664ba..1463e1ce 100644 --- a/spec/workspace-command-completion.spec.mjs +++ b/spec/workspace-command-completion.spec.mjs @@ -898,7 +898,9 @@ function workspaceRenderTheme() { markdown: new Map(), surface: { workspace, + currentLine: workspace, drawer, + header: footer, footer, }, cursor: { diff --git a/spec/workspace-command-line.spec.mjs b/spec/workspace-command-line.spec.mjs index 842fd4b3..ce634216 100644 --- a/spec/workspace-command-line.spec.mjs +++ b/spec/workspace-command-line.spec.mjs @@ -1946,7 +1946,13 @@ function commandLineRenderTheme() { source: new Map(), sourceRoleMap: new Map(), markdown: new Map(), - surface: { workspace, drawer: footer, footer }, + surface: { + workspace, + currentLine: workspace, + drawer: footer, + header: footer, + footer, + }, cursor: { normal: workspace, insert: workspace }, chrome: { activeEdge: edge, diff --git a/spec/workspace-drawer-scroll.spec.mjs b/spec/workspace-drawer-scroll.spec.mjs new file mode 100644 index 00000000..48b0c3ec --- /dev/null +++ b/spec/workspace-drawer-scroll.spec.mjs @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; + +const HEIGHT = 10; + +test("a list shorter than the drawer never scrolls", async () => { + const viewport = await importDist("app", "workspace", "viewport.js"); + + assert.equal(viewport.listScrollOffset(0, 4, HEIGHT), 0); + assert.equal(viewport.listScrollOffset(3, 4, HEIGHT), 0); +}); + +test("an early selection keeps the list at the top", async () => { + const viewport = await importDist("app", "workspace", "viewport.js"); + + assert.equal(viewport.listScrollOffset(0, 100, HEIGHT), 0); + assert.equal(viewport.listScrollOffset(2, 100, HEIGHT), 0); +}); + +test("the window follows a selection past the visible rows", async () => { + const viewport = await importDist("app", "workspace", "viewport.js"); + const offset = viewport.listScrollOffset(50, 100, HEIGHT); + + assert.ok(offset > 0); + assert.ok(50 >= offset && 50 < offset + HEIGHT); +}); + +test("the last entry is reachable and the window stops there", async () => { + const viewport = await importDist("app", "workspace", "viewport.js"); + const offset = viewport.listScrollOffset(99, 100, HEIGHT); + + assert.equal(offset, 100 - HEIGHT); + assert.ok(99 >= offset && 99 < offset + HEIGHT); +}); + +test("the selection is always inside the window for every position", async () => { + const viewport = await importDist("app", "workspace", "viewport.js"); + const total = 57; + const offenders = []; + + for (let index = 0; index < total; index += 1) { + const offset = viewport.listScrollOffset(index, total, HEIGHT); + const visible = index >= offset && index < offset + HEIGHT; + const bounded = offset >= 0 && offset <= Math.max(0, total - HEIGHT); + if (!visible || !bounded) { + offenders.push(`index=${index} offset=${offset}`); + } + } + + assert.deepEqual(offenders, []); +}); + +test("a click maps through the scroll offset to the right entry", async () => { + const [mouse, viewport, panelFocus, titleScreen] = await Promise.all([ + importDist("app", "workspace", "mouse.js"), + importDist("app", "workspace", "viewport.js"), + importDist("ui", "panel-focus.js"), + importDist("ui", "title-screen.js"), + ]); + const { mockTitleScreenModel } = await import("./workspace-helpers.mjs"); + const entries = Array.from({ length: 100 }, (_, index) => ({ + kind: "file", + name: `entry-${index}.txt`, + path: `/w/entry-${index}.txt`, + isDirectory: false, + })); + const model = mockTitleScreenModel(titleScreen, { + editor: undefined, + entries, + selectedIndex: 60, + fileDrawerOpen: true, + fileDrawerProgress: 1, + focusPane: panelFocus.FocusPanes.Files, + startupIntroComplete: true, + columns: 100, + rows: 30, + }); + const bodyHeight = viewport.workspaceBodyHeight({ + rows: model.rows, + footerVisible: model.footerVisible, + }); + const listHeight = bodyHeight - (viewport.DRAWER_INNER_PAD * 2); + const offset = viewport.listScrollOffset(60, entries.length, listHeight); + const firstRow = + viewport.WORKSPACE_BODY_TOP_OFFSET + viewport.DRAWER_INNER_PAD; + + const [next] = mouse.updateFromMouse( + { + type: "mouse", + button: "left", + action: "press", + col: 2, + row: firstRow, + shift: false, + alt: false, + ctrl: false, + }, + model, + { highlight: () => undefined }, + ); + + assert.equal(next.selectedIndex, offset); +}); + +test("clicking a file opens it, not just selects it", async () => { + const [mouse, viewport, panelFocus, titleScreen] = await Promise.all([ + importDist("app", "workspace", "mouse.js"), + importDist("app", "workspace", "viewport.js"), + importDist("ui", "panel-focus.js"), + importDist("ui", "title-screen.js"), + ]); + const { mockTitleScreenModel } = await import("./workspace-helpers.mjs"); + const opened = []; + const model = mockTitleScreenModel(titleScreen, { + editor: undefined, + entries: [ + { kind: "file", name: "alpha.txt", path: "/w/alpha.txt", isDirectory: false }, + { kind: "file", name: "beta.txt", path: "/w/beta.txt", isDirectory: false }, + ], + selectedIndex: 0, + fileDrawerOpen: true, + fileDrawerProgress: 1, + focusPane: panelFocus.FocusPanes.Files, + startupIntroComplete: true, + columns: 100, + rows: 30, + }); + const row = viewport.WORKSPACE_BODY_TOP_OFFSET + viewport.DRAWER_INNER_PAD + 1; + + mouse.updateFromMouse( + { type: "mouse", button: "left", action: "press", col: 2, row, shift: false, alt: false, ctrl: false }, + model, + { highlight: () => undefined }, + { + nowMs: () => 0, + openEntry: (nextModel, entry) => { + opened.push(entry.path); + return [nextModel, []]; + }, + }, + ); + + assert.deepEqual(opened, ["/w/beta.txt"]); +}); diff --git a/spec/workspace-fast-startup.spec.mjs b/spec/workspace-fast-startup.spec.mjs new file mode 100644 index 00000000..3f2be73b --- /dev/null +++ b/spec/workspace-fast-startup.spec.mjs @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; +import { mockI18n, mockJeditTheme, mockRuntime, REPO_ROOT } from "./workspace-helpers.mjs"; + +const VIEWER_WIDTH = 120; +const VIEWER_HEIGHT = 32; +const FIXED_SEED = 0.5; +const STARTUP_NOW_MS = 0; + +test("startup snapshot loads no title meshes", async () => { + const adapter = await importDist( + "adapters", + "workspace-initial-model-snapshot.js", + ); + + const snapshot = adapter.createInitialModelSnapshot( + STARTUP_NOW_MS, + REPO_ROOT, + () => FIXED_SEED, + ); + + assert.deepEqual(Object.keys(snapshot.titleMeshes ?? {}), []); +}); + +test("startup snapshot preloads no title scene", async () => { + const adapter = await importDist( + "adapters", + "workspace-initial-model-snapshot.js", + ); + + const snapshot = adapter.createInitialModelSnapshot( + STARTUP_NOW_MS, + REPO_ROOT, + () => FIXED_SEED, + ); + + assert.equal(snapshot.sceneOverride == null, true); + assert.equal(snapshot.sceneOverrideName == null, true); +}); + +test("a workspace with no open file renders no title backdrop", async () => { + const [init, viewerContent] = await Promise.all([ + importDist("app", "workspace", "init.js"), + importDist("app", "workspace", "viewer-content.js"), + ]); + const model = init.createInitialModel(REPO_ROOT, VIEWER_WIDTH, VIEWER_HEIGHT, { + entries: [], + titleSceneSeed: FIXED_SEED, + jeditTheme: mockJeditTheme(), + i18n: mockI18n(), + nowMs: STARTUP_NOW_MS, + }); + + const surface = viewerContent.renderViewer( + model, + VIEWER_WIDTH, + VIEWER_HEIGHT, + ); + let painted = 0; + for (let row = 0; row < VIEWER_HEIGHT; row += 1) { + for (let column = 0; column < VIEWER_WIDTH; column += 1) { + const cell = surface.get(column, row); + if (cell?.char != null && cell.char.trim() !== "") { + painted += 1; + } + } + } + + assert.equal(painted, 0); +}); + +test("no title scene stats are reported when no backdrop is drawn", async () => { + const [init, stats] = await Promise.all([ + importDist("app", "workspace", "init.js"), + importDist("app", "workspace", "title-scene-performance-stats.js"), + ]); + const model = init.createInitialModel(REPO_ROOT, VIEWER_WIDTH, VIEWER_HEIGHT, { + entries: [], + titleSceneSeed: FIXED_SEED, + jeditTheme: mockJeditTheme(), + i18n: mockI18n(), + nowMs: STARTUP_NOW_MS, + }); + + assert.equal(stats.titleScenePerformanceStats(model), undefined); +}); + +test("title scene stats are reported once the legacy backdrop is selected", async () => { + const [init, stats, titleScreen] = await Promise.all([ + importDist("app", "workspace", "init.js"), + importDist("app", "workspace", "title-scene-performance-stats.js"), + importDist("ui", "title-screen.js"), + ]); + const model = init.createInitialModel(REPO_ROOT, VIEWER_WIDTH, VIEWER_HEIGHT, { + entries: [], + titleSceneSeed: FIXED_SEED, + jeditTheme: mockJeditTheme(), + i18n: mockI18n(), + nowMs: STARTUP_NOW_MS, + }); + + const reported = stats.titleScenePerformanceStats({ + ...model, + titleBackdropKind: titleScreen.TITLE_BACKDROP_KIND.LegacyScene, + }); + + assert.notEqual(reported, undefined); + assert.ok(reported.rayCount > 0); +}); + +test("an idle time tick returns the same model so nothing re-renders", async () => { + const [runtimeModule, titleScreen] = await Promise.all([ + importDist("app", "workspace", "runtime.js"), + importDist("ui", "title-screen.js"), + ]); + const runtime = runtimeModule.createWorkspaceRuntime(mockRuntime()); + const idle = { + ...idleWorkspaceModel(titleScreen), + perfVisible: false, + }; + + const [next, commands] = runtime.update({ type: "time-tick", time: 1 }, idle); + + assert.equal(next, idle); + assert.deepEqual(commands, []); +}); + +test("a time tick still animates while the perf overlay is visible", async () => { + const [runtimeModule, titleScreen] = await Promise.all([ + importDist("app", "workspace", "runtime.js"), + importDist("ui", "title-screen.js"), + ]); + const runtime = runtimeModule.createWorkspaceRuntime(mockRuntime()); + const watching = { + ...idleWorkspaceModel(titleScreen), + perfVisible: true, + }; + + const [next] = runtime.update({ type: "time-tick", time: 1 }, watching); + + assert.notEqual(next, watching); + assert.equal(next.time, 1); +}); + +test("a time tick still animates while the legacy title backdrop is drawn", async () => { + const [runtimeModule, titleScreen] = await Promise.all([ + importDist("app", "workspace", "runtime.js"), + importDist("ui", "title-screen.js"), + ]); + const runtime = runtimeModule.createWorkspaceRuntime(mockRuntime()); + const tracing = { + ...idleWorkspaceModel(titleScreen), + titleBackdropKind: titleScreen.TITLE_BACKDROP_KIND.LegacyScene, + }; + + const [next] = runtime.update({ type: "time-tick", time: 1 }, tracing); + + assert.notEqual(next, tracing); +}); + +function idleWorkspaceModel(titleScreen) { + return { + time: 0, + lastFrameMs: 0, + frameTimeMs: 0, + frameTimeHistory: [], + startupIntroComplete: true, + perfVisible: false, + profiler: { active: false }, + titleBackdropKind: titleScreen.TITLE_BACKDROP_KIND.StaticLogo, + }; +} diff --git a/spec/workspace-footer.spec.mjs b/spec/workspace-footer.spec.mjs index cdbcce28..e93d1a06 100644 --- a/spec/workspace-footer.spec.mjs +++ b/spec/workspace-footer.spec.mjs @@ -46,7 +46,7 @@ test('workspace footer shows compact uppercase normal-mode guidance', async () = assert.deepEqual( footer.workspaceFooterLines(idleNormalState()), [ - 'NORMAL [i insert · o open line · f3 preview · ctrl+t theme]', + 'NORMAL [i insert · o open line · f3 preview]', '/repo/notes/todo.md', ], ); @@ -61,7 +61,7 @@ test('workspace footer can surface the last command provenance summary', async ( commandSummary: 'last: dw delete motion 0..6 receipt receipt:dw', }), [ - 'NORMAL [last: dw delete motion 0..6 receipt receipt:dw · i insert · o open line · f3 preview · ctrl+t theme]', + 'NORMAL [last: dw delete motion 0..6 receipt receipt:dw · i insert · o open line · f3 preview]', '/repo/notes/todo.md', ], ); @@ -79,7 +79,7 @@ test('workspace footer shows editor cursor position when available', async () => }, }), [ - 'NORMAL 6:1 [i insert · o open line · f3 preview · ctrl+t theme]', + 'NORMAL 6:1 [i insert · o open line · f3 preview]', '/repo/notes/todo.md', ], ); @@ -217,7 +217,7 @@ test('workspace footer explains that tab indents when no peer panes are visible editorMode: 'insert', }), [ - 'INSERT [text input · esc normal · ctrl+s save · ctrl+t theme · tab indent]', + 'INSERT [text input · esc normal · ctrl+s save · tab indent]', '/repo/notes/todo.md', ], ); @@ -232,7 +232,7 @@ test('workspace footer shows f3 as the Markdown preview source toggle', async () viewMode: 'preview', }), [ - 'PREVIEW [j/k scroll · f3 source · ctrl+t theme · ctrl+b files · ctrl+g graft]', + 'PREVIEW [j/k scroll · f3 source · ctrl+b files · ctrl+g graft]', '/repo/notes/todo.md', ], ); @@ -263,7 +263,7 @@ test('workspace footer shows file drawer controls and the selected file path', a graftSelection: undefined, }), [ - 'FILES [j/k move · enter open · backspace up · ctrl+b close · ctrl+t theme · tab focus]', + 'FILES [j/k move · enter open · backspace up · ctrl+b close · tab focus]', '/repo/notes/very-long-file-name.md', ], ); @@ -335,7 +335,7 @@ test('workspace footer obtains the scene picker hint label from i18n', async () graftSelection: undefined, }), [ - 'BROWSE [ctrl+l scene picker · ctrl+t theme · ctrl+b files · ctrl+g graft]', + 'BROWSE [ctrl+l scene picker · ctrl+b files · ctrl+g graft]', '/repo', ], ); @@ -466,3 +466,20 @@ function rowText(surface, row) { } return text; } + +test('the footer does not advertise the theme shortcut in any mode', async () => { + const chrome = await loadFooterModule(); + const modes = [ + { label: 'normal', state: idleNormalState() }, + { label: 'insert', state: { ...idleNormalState(), editorMode: 'insert' } }, + { label: 'files', state: { ...idleNormalState(), focusPane: 'files', fileDrawerOpen: true } }, + { label: 'graft', state: { ...idleNormalState(), focusPane: 'graft', graftDrawerOpen: true } }, + { label: 'settings', state: { ...idleNormalState(), settingsOpen: true } }, + { label: 'no-editor', state: { ...idleNormalState(), editorMode: undefined, editorPath: undefined } }, + ]; + const offenders = modes.filter(({ state }) => ( + chrome.workspaceFooterLines(state).join(' ').includes('theme') + )).map(({ label }) => label); + + assert.deepEqual(offenders, []); +}); diff --git a/spec/workspace-frame-clock.spec.mjs b/spec/workspace-frame-clock.spec.mjs new file mode 100644 index 00000000..9edd2a04 --- /dev/null +++ b/spec/workspace-frame-clock.spec.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; +import { mockRuntime } from "./workspace-helpers.mjs"; + +function idleWorkspaceModel(titleScreen) { + return { + time: 0, + lastFrameMs: 0, + frameTimeMs: 0, + frameTimeHistory: [], + startupIntroComplete: true, + perfVisible: false, + profiler: { active: false }, + titleBackdropKind: titleScreen.TITLE_BACKDROP_KIND.StaticLogo, + }; +} + +// The idle gate returns the same model so nothing re-renders, which also means +// lastFrameMs stops advancing while the workspace sits still. If animation is +// later switched back on, the first active tick would otherwise bill the whole +// idle interval as a single frame: instantly over budget, which trips the +// backdrop's low-rate flag and leaves the animation frozen on every frame after. + +const IDLE_MS = 30_000; +const PLAUSIBLE_FRAME_MS = 100; + +async function runtimeAt(clock) { + const runtimeModule = await importDist("app", "workspace", "runtime.js"); + return runtimeModule.createWorkspaceRuntime({ + ...mockRuntime(), + nowMs: () => clock.now, + }); +} + +test("resuming animation after an idle gap starts a fresh frame clock", async () => { + const titleScreen = await importDist("ui", "title-screen.js"); + const clock = { now: 0 }; + const runtime = await runtimeAt(clock); + + const idle = { + ...idleWorkspaceModel(titleScreen), + perfVisible: false, + lastFrameMs: 0, + }; + + // Sit idle. Ticks keep arriving; the gate returns the same model each time. + clock.now = IDLE_MS; + const [stillIdle] = runtime.update({ type: "time-tick", time: 1 }, idle); + assert.equal(stillIdle, idle, "an idle tick must not produce a new model"); + + // The user switches the perf overlay on, which reactivates animation. + const [watching] = runtime.update({ type: "toggle-perf" }, stillIdle); + assert.equal(watching.perfVisible, true); + + // The next tick is the first active frame. + clock.now = IDLE_MS + 16; + const [animating] = runtime.update({ type: "time-tick", time: 2 }, watching); + + assert.ok( + animating.frameTimeMs < PLAUSIBLE_FRAME_MS, + `first resumed frame billed ${animating.frameTimeMs}ms of idle time as one frame`, + ); +}); + +test("frame timing is untouched while animation stays active", async () => { + const titleScreen = await importDist("ui", "title-screen.js"); + const clock = { now: 0 }; + const runtime = await runtimeAt(clock); + + const watching = { + ...idleWorkspaceModel(titleScreen), + perfVisible: true, + lastFrameMs: 0, + }; + + clock.now = 16; + const [first] = runtime.update({ type: "time-tick", time: 1 }, watching); + assert.equal(first.frameTimeMs, 16); + + clock.now = 33; + const [second] = runtime.update({ type: "time-tick", time: 2 }, first); + assert.equal(second.frameTimeMs, 17, "an ordinary frame must be measured normally"); +}); diff --git a/spec/workspace-helpers.mjs b/spec/workspace-helpers.mjs index b82b33b6..505c70b1 100644 --- a/spec/workspace-helpers.mjs +++ b/spec/workspace-helpers.mjs @@ -463,7 +463,9 @@ export function mockJeditTheme() { variables: new Map(), surface: { workspace, + currentLine: drawer, drawer, + header: drawer, footer: drawer, }, cursor: { @@ -560,6 +562,7 @@ export function mockTitleScreenModel(titleScreen, overrides = {}) { crouching: false, }, titleMouseLook: undefined, + titleBackdropKind: titleScreen.TITLE_BACKDROP_KIND.StaticLogo, titleRenderMode: titleScreen.TITLE_RENDER_MODE.Braille, titleAsciiPalette: titleScreen.TITLE_ASCII_PALETTE.Dense, titleMeshMaterialIndex: 0, diff --git a/spec/workspace-key-bindings.spec.mjs b/spec/workspace-key-bindings.spec.mjs index 5b4832e4..5b7602f4 100644 --- a/spec/workspace-key-bindings.spec.mjs +++ b/spec/workspace-key-bindings.spec.mjs @@ -98,6 +98,10 @@ test("ctrl-l opens the title scene picker when no editor is active", async () => ); assert.equal(nextModel.scenePickerOpen, true); + assert.equal( + nextModel.titleBackdropKind, + titleScreen.TITLE_BACKDROP_KIND.LegacyScene, + ); }); test("ctrl-h cannot open a process-local Echo history pane", async () => { diff --git a/spec/workspace-list-navigation.spec.mjs b/spec/workspace-list-navigation.spec.mjs new file mode 100644 index 00000000..b45d3cfd --- /dev/null +++ b/spec/workspace-list-navigation.spec.mjs @@ -0,0 +1,226 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; +import { + mockKeyBindingContext, + mockTitleScreenModel, +} from "./workspace-helpers.mjs"; + +const ENTRIES = Object.freeze([ + { name: "alpha.txt", isDirectory: false }, + { name: "beta.txt", isDirectory: false }, + { name: "gamma.txt", isDirectory: false }, +]); +const LAST_INDEX = ENTRIES.length - 1; +const FIRST_INDEX = 0; + +async function fileDrawerModel(overrides = {}) { + const [titleScreen, panelFocus] = await Promise.all([ + importDist("ui", "title-screen.js"), + importDist("ui", "panel-focus.js"), + ]); + return mockTitleScreenModel(titleScreen, { + editor: undefined, + entries: ENTRIES, + selectedIndex: FIRST_INDEX, + fileDrawerOpen: true, + focusPane: panelFocus.FocusPanes.Files, + startupIntroComplete: true, + ...overrides, + }); +} + +test("down arrow moves the file explorer selection", async () => { + const keyBindings = await importDist("app", "workspace", "key-bindings.js"); + const model = await fileDrawerModel(); + + const [next] = keyBindings.updateFromKey( + { key: "down" }, + model, + mockKeyBindingContext(), + ); + + assert.equal(next.selectedIndex, 1); +}); + +test("up arrow moves the file explorer selection", async () => { + const keyBindings = await importDist("app", "workspace", "key-bindings.js"); + const model = await fileDrawerModel({ selectedIndex: 1 }); + + const [next] = keyBindings.updateFromKey( + { key: "up" }, + model, + mockKeyBindingContext(), + ); + + assert.equal(next.selectedIndex, FIRST_INDEX); +}); + +test("arrow keys in the file explorer never start the ray-traced backdrop", async () => { + const [keyBindings, titleScreen] = await Promise.all([ + importDist("app", "workspace", "key-bindings.js"), + importDist("ui", "title-screen.js"), + ]); + const model = await fileDrawerModel(); + + const [next] = keyBindings.updateFromKey( + { key: "down" }, + model, + mockKeyBindingContext(), + ); + + assert.notEqual( + next.titleBackdropKind, + titleScreen.TITLE_BACKDROP_KIND.LegacyScene, + ); +}); + +test("the file explorer selection wraps from the last entry to the first", async () => { + const keyBindings = await importDist("app", "workspace", "key-bindings.js"); + const model = await fileDrawerModel({ selectedIndex: LAST_INDEX }); + + const [next] = keyBindings.updateFromKey( + { key: "j" }, + model, + mockKeyBindingContext(), + ); + + assert.equal(next.selectedIndex, FIRST_INDEX); +}); + +test("the file explorer selection wraps from the first entry to the last", async () => { + const keyBindings = await importDist("app", "workspace", "key-bindings.js"); + const model = await fileDrawerModel({ selectedIndex: FIRST_INDEX }); + + const [next] = keyBindings.updateFromKey( + { key: "k" }, + model, + mockKeyBindingContext(), + ); + + assert.equal(next.selectedIndex, LAST_INDEX); +}); + +test("an empty file explorer stays put instead of wrapping onto nothing", async () => { + const keyBindings = await importDist("app", "workspace", "key-bindings.js"); + const model = await fileDrawerModel({ entries: [], selectedIndex: 0 }); + + const [next] = keyBindings.updateFromKey( + { key: "j" }, + model, + mockKeyBindingContext(), + ); + + assert.equal(next.selectedIndex, 0); +}); + +test("mouse movement over the file explorer never starts the ray-traced backdrop", async () => { + const [runtimeModule, titleScreen] = await Promise.all([ + importDist("app", "workspace", "mouse.js"), + importDist("ui", "title-screen.js"), + ]); + const model = await fileDrawerModel(); + + const [next] = runtimeModule.updateFromMouse( + { type: "mouse", button: "none", action: "move", col: 4, row: 6, shift: false, alt: false, ctrl: false }, + model, + { highlight: () => undefined }, + ); + + assert.notEqual( + next.titleBackdropKind, + titleScreen.TITLE_BACKDROP_KIND.LegacyScene, + ); +}); + +test("the wheel still scrolls the focused file explorer", async () => { + const mouse = await importDist("app", "workspace", "mouse.js"); + const model = await fileDrawerModel(); + + const [next] = mouse.updateFromMouse( + { type: "mouse", button: "none", action: "scroll-down", col: 4, row: 6, shift: false, alt: false, ctrl: false }, + model, + { highlight: () => undefined }, + ); + + assert.notEqual(next.selectedIndex, model.selectedIndex); +}); + +test("the title keeps naming the open document while browsing other files", async () => { + const chrome = await importDist("ui", "workspace-chrome.js"); + const highlighted = { kind: "file", name: "beta.txt", path: "/w/beta.txt" }; + + const title = chrome.activeWorkspaceTitle({ + cwd: "/w", + editorPath: "/w/foo.txt", + editorDirty: false, + selectedEntry: highlighted, + }); + + assert.equal(title, "foo.txt"); +}); + +test("the title names the highlighted file only when nothing is open", async () => { + const chrome = await importDist("ui", "workspace-chrome.js"); + const highlighted = { kind: "file", name: "beta.txt", path: "/w/beta.txt" }; + + const title = chrome.activeWorkspaceTitle({ + cwd: "/w", + editorPath: undefined, + editorDirty: false, + selectedEntry: highlighted, + }); + + assert.equal(title, "beta.txt"); +}); + +test("clicking a file explorer row selects that entry", async () => { + const [mouse, viewport] = await Promise.all([ + importDist("app", "workspace", "mouse.js"), + importDist("app", "workspace", "viewport.js"), + ]); + const model = await fileDrawerModel({ columns: 100, rows: 30, fileDrawerProgress: 1 }); + const clickRow = viewport.WORKSPACE_BODY_TOP_OFFSET + viewport.DRAWER_INNER_PAD + 2; + + const [next] = mouse.updateFromMouse( + { type: "mouse", button: "left", action: "press", col: 2, row: clickRow, shift: false, alt: false, ctrl: false }, + model, + { highlight: () => undefined }, + ); + + assert.equal(next.selectedIndex, 2); +}); + +test("clicking outside the file explorer leaves the selection alone", async () => { + const [mouse, viewport] = await Promise.all([ + importDist("app", "workspace", "mouse.js"), + importDist("app", "workspace", "viewport.js"), + ]); + const model = await fileDrawerModel({ columns: 100, rows: 30, fileDrawerProgress: 1, selectedIndex: 1 }); + const clickRow = viewport.WORKSPACE_BODY_TOP_OFFSET + viewport.DRAWER_INNER_PAD + 2; + + const [next] = mouse.updateFromMouse( + { type: "mouse", button: "left", action: "press", col: 95, row: clickRow, shift: false, alt: false, ctrl: false }, + model, + { highlight: () => undefined }, + ); + + assert.equal(next.selectedIndex, 1); +}); + +test("clicking past the last entry leaves the selection alone", async () => { + const [mouse, viewport] = await Promise.all([ + importDist("app", "workspace", "mouse.js"), + importDist("app", "workspace", "viewport.js"), + ]); + const model = await fileDrawerModel({ columns: 100, rows: 30, fileDrawerProgress: 1, selectedIndex: 1 }); + const clickRow = viewport.WORKSPACE_BODY_TOP_OFFSET + viewport.DRAWER_INNER_PAD + 20; + + const [next] = mouse.updateFromMouse( + { type: "mouse", button: "left", action: "press", col: 2, row: clickRow, shift: false, alt: false, ctrl: false }, + model, + { highlight: () => undefined }, + ); + + assert.equal(next.selectedIndex, 1); +}); diff --git a/spec/workspace-runtime.spec.mjs b/spec/workspace-runtime.spec.mjs index fafb029d..61ee33af 100644 --- a/spec/workspace-runtime.spec.mjs +++ b/spec/workspace-runtime.spec.mjs @@ -216,7 +216,10 @@ test("workspace perf overlay adds title-scene facts only on title screen", async importDist("adapters", "workspace-perf-app.js"), importDist("ui", "title-screen.js"), ]); - const model = mockPerfTitleModel(titleScreen); + const model = { + ...mockPerfTitleModel(titleScreen), + titleBackdropKind: titleScreen.TITLE_BACKDROP_KIND.LegacyScene, + }; const app = workspacePerfApp.createPerfApp(surfaceOnlyApp(), { initialPerfVisible: true, }); diff --git a/spec/workspace-shutdown-guard.spec.mjs b/spec/workspace-shutdown-guard.spec.mjs new file mode 100644 index 00000000..418c10c7 --- /dev/null +++ b/spec/workspace-shutdown-guard.spec.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; + +// The native Echo host owns a child process holding stdio pipes open. Anything +// that escapes startup without closing it leaves the terminal unrestored and +// the event loop alive, which is the failure #306 was filed for. Building the +// app is the part that can throw, so it has to be inside the guard. + +function recordingDependencies() { + const closed = []; + return { + closed, + dependencies: { + closeProductionText: async () => { + closed.push("closed"); + }, + }, + }; +} + +test("the Echo host is closed after a normal run", async () => { + const { closingProductionText } = await importDist( + "adapters", + "workspace-production-text-dependencies.js", + ); + const { closed, dependencies } = recordingDependencies(); + + const result = await closingProductionText(dependencies, async () => "ran"); + + assert.equal(result, "ran"); + assert.deepEqual(closed, ["closed"]); +}); + +test("the Echo host is closed when app construction throws", async () => { + const { closingProductionText } = await importDist( + "adapters", + "workspace-production-text-dependencies.js", + ); + const { closed, dependencies } = recordingDependencies(); + + await assert.rejects( + closingProductionText(dependencies, async () => { + throw new Error("createWorkspaceApp failed"); + }), + /createWorkspaceApp failed/, + ); + + assert.deepEqual(closed, ["closed"], "the host must close even on a failed startup"); +}); + +test("the original failure is not masked by the close", async () => { + const { closingProductionText } = await importDist( + "adapters", + "workspace-production-text-dependencies.js", + ); + + await assert.rejects( + closingProductionText( + { closeProductionText: async () => undefined }, + async () => { + throw new Error("the real cause"); + }, + ), + /the real cause/, + ); +}); diff --git a/spec/workspace-shutdown.spec.mjs b/spec/workspace-shutdown.spec.mjs new file mode 100644 index 00000000..273f4267 --- /dev/null +++ b/spec/workspace-shutdown.spec.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; + +function fakeEchoHost(record) { + return { + openBuffer: async () => undefined, + replaceRange: async () => undefined, + declareCheckpoint: async () => undefined, + observeWindow: async () => undefined, + close: async () => { + record.closed += 1; + }, + }; +} + +test("production text dependencies expose a way to shut the host down", async () => { + const deps = await importDist( + "adapters", + "workspace-production-text-dependencies.js", + ); + const record = { closed: 0 }; + + const created = await deps.createWorkspaceProductionTextDependencies( + () => fakeEchoHost(record), + ); + + assert.equal(typeof created.closeProductionText, "function"); +}); + +test("shutting the dependencies down closes the native Echo host", async () => { + const deps = await importDist( + "adapters", + "workspace-production-text-dependencies.js", + ); + const record = { closed: 0 }; + const created = await deps.createWorkspaceProductionTextDependencies( + () => fakeEchoHost(record), + ); + + await created.closeProductionText(); + + assert.equal(record.closed, 1); +}); + +test("shutting down tolerates a host that cannot close", async () => { + const deps = await importDist( + "adapters", + "workspace-production-text-dependencies.js", + ); + const created = await deps.createWorkspaceProductionTextDependencies(() => ({ + openBuffer: async () => undefined, + replaceRange: async () => undefined, + declareCheckpoint: async () => undefined, + observeWindow: async () => undefined, + })); + + await assert.doesNotReject(() => created.closeProductionText()); +}); diff --git a/spec/workspace-startup-instrumentation.spec.mjs b/spec/workspace-startup-instrumentation.spec.mjs new file mode 100644 index 00000000..2da35239 --- /dev/null +++ b/spec/workspace-startup-instrumentation.spec.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; + +const NO_ENV = Object.freeze({}); +const ENABLED = "1"; +const DISABLED = "0"; + +test("a plain launch enables no perf overlay", async () => { + const main = await importDist("main-workspace.js"); + + assert.equal(main.workspaceInstrumentationFromEnv(NO_ENV).perfEnabled, false); +}); + +test("a plain launch starts no profiler trace", async () => { + const main = await importDist("main-workspace.js"); + + assert.equal( + main.workspaceInstrumentationFromEnv(NO_ENV).profileEnabled, + false, + ); +}); + +test("instrumentation still turns on when explicitly asked for", async () => { + const main = await importDist("main-workspace.js"); + const enabled = main.workspaceInstrumentationFromEnv({ + [main.WORKSPACE_INSTRUMENTATION_ENV.Perf]: ENABLED, + [main.WORKSPACE_INSTRUMENTATION_ENV.Profile]: ENABLED, + }); + + assert.equal(enabled.perfEnabled, true); + assert.equal(enabled.profileEnabled, true); +}); + +test("instrumentation stays off when explicitly disabled", async () => { + const main = await importDist("main-workspace.js"); + const disabled = main.workspaceInstrumentationFromEnv({ + [main.WORKSPACE_INSTRUMENTATION_ENV.Perf]: DISABLED, + [main.WORKSPACE_INSTRUMENTATION_ENV.Profile]: DISABLED, + }); + + assert.equal(disabled.perfEnabled, false); + assert.equal(disabled.profileEnabled, false); +}); diff --git a/spec/workspace-title-performance-governor.spec.mjs b/spec/workspace-title-performance-governor.spec.mjs index ef9db0f3..c9a9f6fb 100644 --- a/spec/workspace-title-performance-governor.spec.mjs +++ b/spec/workspace-title-performance-governor.spec.mjs @@ -12,6 +12,28 @@ const TITLE_HEIGHT = 6; const SLOW_FRAME_MS = 90; const FAST_FRAME_MS = 12; +test("default Jim logo renderer reports static no-ray performance facts", async () => { + const [viewerContent, titleScreen] = await Promise.all([ + importDist("app", "workspace", "viewer-content.js"), + importDist("ui", "title-screen.js"), + ]); + const renderer = viewerContent.createViewerContentRenderer(); + renderer.renderViewer( + mockTitleScreenModel(titleScreen, { startupIntroComplete: true }), + TITLE_WIDTH, + TITLE_HEIGHT, + ); + + assert.deepEqual(renderer.titleScenePerformanceFacts(), { + posture: "static-title", + tracesRays: false, + usesFrozenBackdrop: false, + retainsBackdrop: false, + inputLatencyPosture: "static-title", + frameBudgetPosture: "within-budget", + }); +}); + test("title scene performance governor selects low-rate frozen backdrop with ray tracing disabled", async () => { const governor = await importDist( "app", diff --git a/spec/workspace-title-screen.spec.mjs b/spec/workspace-title-screen.spec.mjs index ee8615c8..d0397274 100644 --- a/spec/workspace-title-screen.spec.mjs +++ b/spec/workspace-title-screen.spec.mjs @@ -17,16 +17,19 @@ const STARTUP_MODAL_SCROLL_SELECTED_INDEX = 8; const STARTUP_MODAL_SCROLLBAR_TRACK_CHAR = "│"; const STARTUP_MODAL_SCROLLBAR_THUMB_CHAR = "█"; -test("title screen number keys switch render modes without an editor", async () => { - const [keyBindings, titleScreen] = await Promise.all([ +test("title screen number keys switch to a visible legacy render mode", async () => { + const [keyBindings, titleScreen, viewerContent] = await Promise.all([ importDist("app", "workspace", "key-bindings.js"), importDist("ui", "title-screen.js"), + importDist("app", "workspace", "viewer-content.js"), ]); + const base = mockTitleScreenModel(titleScreen, { + startupIntroComplete: true, + titleRenderMode: titleScreen.TITLE_RENDER_MODE.Braille, + }); const [asciiModel] = keyBindings.updateFromKey( { key: "2" }, - mockTitleScreenModel(titleScreen, { - titleRenderMode: titleScreen.TITLE_RENDER_MODE.Braille, - }), + base, mockKeyBindingContext(), ); const [brailleModel] = keyBindings.updateFromKey( @@ -38,10 +41,18 @@ test("title screen number keys switch render modes without an editor", async () ); assert.equal(asciiModel.titleRenderMode, titleScreen.TITLE_RENDER_MODE.Ascii); + assert.equal( + asciiModel.titleBackdropKind, + titleScreen.TITLE_BACKDROP_KIND.LegacyScene, + ); assert.equal( brailleModel.titleRenderMode, titleScreen.TITLE_RENDER_MODE.Braille, ); + assert.equal( + brailleModel.titleBackdropKind, + titleScreen.TITLE_BACKDROP_KIND.LegacyScene, + ); assert.equal( hasNotification(asciiModel, "Title shader", "ASCII · Dense"), true, @@ -65,6 +76,10 @@ test("title screen number keys switch render modes without an editor", async () bg: "#0d1117", }, ); + const renderer = viewerContent.createViewerContentRenderer(); + const staticTitle = renderer.renderViewer(base, 80, 24); + const legacyAsciiTitle = renderer.renderViewer(asciiModel, 80, 24); + assert.notEqual(surfaceText(legacyAsciiTitle), surfaceText(staticTitle)); }); test("tab skips startup intro without opening the file browser", async () => { @@ -165,12 +180,20 @@ test("title screen m cycles title materials and reports the material name", asyn const secondPreset = material.titleMeshMaterialPresetAt(2); assert.equal(first.titleMeshMaterialIndex, 1); + assert.equal( + first.titleBackdropKind, + titleScreen.TITLE_BACKDROP_KIND.LegacyScene, + ); assert.deepEqual(first.sceneOverride.objects[0].color, firstPreset.color); assert.equal( hasNotification(first, "Title material", firstPreset.name), true, ); assert.equal(second.titleMeshMaterialIndex, 2); + assert.equal( + second.titleBackdropKind, + titleScreen.TITLE_BACKDROP_KIND.LegacyScene, + ); assert.deepEqual(second.sceneOverride.objects[0].color, secondPreset.color); assert.equal( hasNotification(second, "Title material", secondPreset.name), @@ -208,6 +231,10 @@ test("title screen uses FPS-style camera keys without an editor", async () => { ); assert.ok(forward.titleCamera.position[2] < base.titleCamera.position[2]); + assert.equal( + forward.titleBackdropKind, + titleScreen.TITLE_BACKDROP_KIND.LegacyScene, + ); assert.ok(left.titleCamera.position[0] < base.titleCamera.position[0]); assert.ok(jumped.titleCamera.position[1] > base.titleCamera.position[1]); assert.equal(crouched.titleCamera.crouching, true); @@ -233,6 +260,10 @@ test("title screen mouse movement drags the camera look vector", async () => { ); assert.deepEqual(anchored.titleCamera.position, base.titleCamera.position); + assert.equal( + anchored.titleBackdropKind, + titleScreen.TITLE_BACKDROP_KIND.LegacyScene, + ); assert.deepEqual(rotated.titleCamera.position, base.titleCamera.position); assert.ok(rotated.titleCamera.target[0] > base.titleCamera.target[0]); assert.ok(rotated.titleCamera.target[1] < base.titleCamera.target[1]); @@ -658,8 +689,12 @@ test("workspace title row uses the active theme chrome token", async () => { const titleCell = titleCells.find((cell) => cell.char !== " "); assert.ok(titleCell); - assert.equal(titleCell.bg, theme.chrome.titleLogo.bg); - assert.equal(titleCell.fg, theme.chrome.titleLogo.fg); + assert.equal(titleCell.bg, theme.surface.header.bg); + assert.equal(titleCell.fg, theme.surface.header.fg); + assert.ok( + titleCells.every((cell) => cell.bg === theme.surface.header.bg), + "the complete title row should use the header surface", + ); }); test("startup file selector drawer width follows spring progress", async () => { @@ -1072,6 +1107,17 @@ function fpsTestCamera() { }; } +function titleBodyCells(surface) { + return positionedCells(surface).filter( + ({ y }) => y >= 2 && y < surface.height - 2, + ); +} + +function isBrailleCell(char) { + const codePoint = char.codePointAt(0) ?? 0; + return codePoint >= 0x2800 && codePoint <= 0x28ff; +} + function titleMouse(action, col, row) { return { type: "mouse", diff --git a/src/adapters/title-scene-loader.ts b/src/adapters/title-scene-loader.ts index 3f6a4d73..3229141f 100644 --- a/src/adapters/title-scene-loader.ts +++ b/src/adapters/title-scene-loader.ts @@ -1,5 +1,7 @@ -import * as fs from "node:fs/promises"; -import { existsSync, readFileSync } from "node:fs"; +// The promises API is reached through node:fs rather than a second +// node:fs/promises import, so the sync and async reads in this module cost +// one import between them. +import { existsSync, promises as fs, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { SceneDecodeError, SceneLoadError } from "../domain/errors.js"; @@ -15,7 +17,6 @@ import { TITLE_SCENE_DEFAULT_CAMERA_TARGET, titleSceneCameraPlacementFromPosition, } from "../ui/title-scene-camera.js"; -import type { TitleMeshLibrary } from "../ui/title-mesh-library.js"; import { titleSceneObjectFootprintCenterAt } from "../ui/title-scene-transform.js"; import { BUILT_IN_TITLE_SCENE_NAMES, @@ -24,6 +25,7 @@ import { } from "../ports/title-scene-loader.js"; import { decodeTitleSceneEnvironment } from "./title-scene-environment-decoder.js"; import { decodeSceneObject } from "./title-scene-object-decoder.js"; +import { withBuiltInTitleMeshes, type TitleMeshLibrary } from "./workspace-title-meshes.js"; import { arrayAt, objectAt, @@ -46,6 +48,7 @@ export interface TitleSceneLoaderOptions { const EMPTY_DIRECTION_LENGTH = 0; const BUILT_IN_TITLE_SCENE_SET = new Set(BUILT_IN_TITLE_SCENE_NAMES); + const BUILT_IN_SCENE_CANDIDATE_URLS = [ (name: BuiltInTitleSceneName): URL => new URL(`../scenes/${name}`, import.meta.url), @@ -70,7 +73,7 @@ export function loadBuiltInTitleSceneSync( } return parseTitleSceneText( readFileSync(resolveBuiltInTitleScenePath(name, undefined), "utf8"), - meshes, + withBuiltInTitleMeshes(meshes), ); } @@ -98,7 +101,7 @@ export async function loadBuiltInTitleScene( } return loadTitleSceneFromFile( resolveBuiltInTitleScenePath(name, undefined), - meshes, + withBuiltInTitleMeshes(meshes), ); } @@ -128,7 +131,7 @@ export function createTitleSceneLoaderPort( } return loadTitleSceneFromFile( resolveBuiltInTitleScenePath(name, options.builtInSceneDirectories), - meshes, + withBuiltInTitleMeshes(meshes), ); }, }; diff --git a/src/adapters/workspace-app.ts b/src/adapters/workspace-app.ts index 84a3745f..c4cbec1a 100644 --- a/src/adapters/workspace-app.ts +++ b/src/adapters/workspace-app.ts @@ -17,6 +17,8 @@ import { createWorkspaceNotificationTickCmd, createWorkspaceTimeTickCmd, } from './workspace-animation-commands.js'; +import { openWorkspaceFileEntry } from '../app/workspace/file-tree.js'; +import type { WorkspaceModel } from '../app/workspace/model.js'; export interface WorkspaceAppOptions { initialColumns: number; @@ -72,6 +74,16 @@ function workspaceRuntimeDependencies( profileOnStartup: options.profileEnabled ?? false, initialModel: options.seed ?? createInitialModelSnapshot(nowMs(), options.initialWorkingDirectory, random), nowMs, + openEntry: ( + model: WorkspaceModel, + entry: NonNullable, + ) => openWorkspaceFileEntry(model, entry, nowMs, { + fileSystem: FileSystemPortAdapter, + editorFile, + graftSession, + sourceHighlighter, + productionTextSession: productionText.productionTextSession, + }), createTimeTickCmd: createWorkspaceTimeTickCmd, createNotificationTickCmd: createWorkspaceNotificationTickCmd, createDrawerAnimationCmd: createWorkspaceDrawerAnimationCmd, diff --git a/src/adapters/workspace-initial-model-snapshot.ts b/src/adapters/workspace-initial-model-snapshot.ts index 771ed538..7167470c 100644 --- a/src/adapters/workspace-initial-model-snapshot.ts +++ b/src/adapters/workspace-initial-model-snapshot.ts @@ -1,64 +1,30 @@ import { BijouI18nAdapter } from "./bijou-i18n-adapter.js"; import type { WorkspaceInitialModelSnapshot } from "../app/workspace/init.js"; -import { - DEFAULT_BUILT_IN_TITLE_SCENE_NAME, - type BuiltInTitleSceneName, -} from "../ports/title-scene-loader.js"; -import type { TitleScene } from "../ui/title-scene.js"; import type { TitleMeshLibrary } from "../ui/title-mesh-library.js"; import { JEDIT_THEME_ENV, resolveInitialJeditTheme, } from "../ui/jedit-themes.js"; import { loadEntries } from "./filesystem.js"; -import { loadBuiltInTitleSceneSync } from "./title-scene-loader.js"; -import { loadStartupTitleMeshes } from "./workspace-title-meshes.js"; - -const DEFAULT_TITLE_SCENE_WARNING_PREFIX = - "jedit default title scene unavailable"; -interface StartupTitleScene { - readonly name: BuiltInTitleSceneName; - readonly scene: TitleScene; -} +// Startup loads no title geometry. The ray-traced title subsystem parses +// ~1.75 MB of OBJ text (teapot, dragon, bunny) and that cost belonged to a +// title screen jedit no longer shows on launch. The meshes and scenes remain +// in the tree and load on demand for anything that opts into the legacy +// backdrop; nothing on the startup path pays for them. +const NO_STARTUP_TITLE_MESHES: TitleMeshLibrary = Object.freeze({}); export function createInitialModelSnapshot( nowMs: number, cwd: string, random: () => number, ): WorkspaceInitialModelSnapshot { - const titleMeshes = loadStartupTitleMeshes(); - const startupTitleScene = loadStartupTitleScene(titleMeshes); return { entries: loadEntries(cwd), - titleMeshes, - ...(startupTitleScene == null - ? {} - : { - sceneOverride: startupTitleScene.scene, - sceneOverrideName: startupTitleScene.name, - }), + titleMeshes: NO_STARTUP_TITLE_MESHES, titleSceneSeed: random(), jeditTheme: resolveInitialJeditTheme(process.env[JEDIT_THEME_ENV]), i18n: new BijouI18nAdapter(), nowMs, }; } - -function loadStartupTitleScene( - titleMeshes: TitleMeshLibrary, -): StartupTitleScene | undefined { - try { - return { - name: DEFAULT_BUILT_IN_TITLE_SCENE_NAME, - scene: loadBuiltInTitleSceneSync( - DEFAULT_BUILT_IN_TITLE_SCENE_NAME, - titleMeshes, - ), - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${DEFAULT_TITLE_SCENE_WARNING_PREFIX}: ${message}\n`); - return undefined; - } -} diff --git a/src/adapters/workspace-production-text-dependencies.ts b/src/adapters/workspace-production-text-dependencies.ts index 9027354b..55df2dd7 100644 --- a/src/adapters/workspace-production-text-dependencies.ts +++ b/src/adapters/workspace-production-text-dependencies.ts @@ -1,14 +1,43 @@ +import type { EchoTextContractHostPort } from '../ports/echo-text-contract-host.js'; import type { ProductionTextSession } from '../app/workspace/production-text-session.js'; import { createEchoTextContractHostProcess } from './echo-text-contract-host-process.js'; import { createWorkspaceProductionTextSession } from './workspace-production-text-session.js'; export interface WorkspaceProductionTextDependencies { readonly productionTextSession: ProductionTextSession; + // The native Echo host is a spawned child process whose stdio pipes keep the + // event loop alive. Nothing retained a handle to it, so `close` -- which the + // host has always implemented -- was unreachable and jedit never returned the + // terminal on quit. The disposer is part of the contract now. + readonly closeProductionText: () => Promise; } -export async function createWorkspaceProductionTextDependencies(): Promise { - const echo = createEchoTextContractHostProcess(); +export async function createWorkspaceProductionTextDependencies( + createHost: () => EchoTextContractHostPort = createEchoTextContractHostProcess, +): Promise { + const echo = createHost(); return { productionTextSession: createWorkspaceProductionTextSession(echo), + closeProductionText: async () => { + await echo.close?.(); + }, }; } + +// Runs `use`, then closes the native Echo host whether `use` returned or threw. +// +// The host owns a child process holding stdio pipes open, so anything that +// escapes without closing it leaves the terminal unrestored and the event loop +// alive -- the failure #306 was filed for. Building the app is part of `use`, +// not something that happens before the guard: app construction can throw, and +// when it did the host stayed open. +export async function closingProductionText( + dependencies: WorkspaceProductionTextDependencies, + use: () => Promise, +): Promise { + try { + return await use(); + } finally { + await dependencies.closeProductionText(); + } +} diff --git a/src/adapters/workspace-title-meshes.ts b/src/adapters/workspace-title-meshes.ts index 00fbbc5b..8b654bf4 100644 --- a/src/adapters/workspace-title-meshes.ts +++ b/src/adapters/workspace-title-meshes.ts @@ -16,6 +16,23 @@ import { loadTitleTeapotMeshSource, } from "./title-bunny-mesh.js"; +export type { TitleMeshLibrary }; + +// Startup loads no title geometry, so the workspace model carries an empty mesh +// library and hands it straight to the scene loader. Every built-in scene +// references bunny, teapot or dragon, so without this they all failed to +// decode -- including continuum-gate, the one the picker offers first. The +// meshes are parsed at most once, and only when a scene is actually opened, +// which is what preserves the startup saving that emptied the library. +let onDemandMeshes: TitleMeshLibrary | undefined; + +export function withBuiltInTitleMeshes(meshes: TitleMeshLibrary): TitleMeshLibrary { + onDemandMeshes ??= loadStartupTitleMeshes(); + // The caller's library wins, so a scene opened with an explicitly supplied + // mesh keeps it and only the gaps are filled. + return { ...onDemandMeshes, ...meshes }; +} + export function loadStartupTitleMeshes(): TitleMeshLibrary { return { bunny: loadStartupTitleMesh( diff --git a/src/app/list-index.ts b/src/app/list-index.ts new file mode 100644 index 00000000..edadc1b9 --- /dev/null +++ b/src/app/list-index.ts @@ -0,0 +1,17 @@ +// Pure index arithmetic for selectable lists. A leaf module with no +// dependencies so every surface that moves a selection -- the file explorer, +// the Graft drawer, the scene preview -- shares one implementation instead of +// each growing its own copy. + +const EMPTY_SIZE = 0; +const FIRST_INDEX = 0; + +// Selection cycles rather than stopping at either end. An empty list has no +// entry to land on, so it stays at the first index instead of wrapping onto +// nothing. +export function wrapIndex(index: number, size: number): number { + if (size <= EMPTY_SIZE) { + return FIRST_INDEX; + } + return ((index % size) + size) % size; +} diff --git a/src/app/title-camera-session.ts b/src/app/title-camera-session.ts index e53bb14c..ac50ae04 100644 --- a/src/app/title-camera-session.ts +++ b/src/app/title-camera-session.ts @@ -377,7 +377,7 @@ function titleCameraSpringCommand( emit(titleCameraFrame(axis, motionId, state.value)); if (state.done) { pulse.dispose(); - resolve(); + resolve(undefined); } }); }); diff --git a/src/app/title-scene-preview-session.ts b/src/app/title-scene-preview-session.ts index 2ab9d801..469fa045 100644 --- a/src/app/title-scene-preview-session.ts +++ b/src/app/title-scene-preview-session.ts @@ -12,6 +12,7 @@ import { TITLE_SCENE_PREVIEW_INPUT, type TitleScenePreviewInput, } from "./title-scene-preview-input.js"; +import { wrapIndex } from './list-index.js'; export { TITLE_SCENE_PREVIEW_INPUT, @@ -483,9 +484,3 @@ function clampIndex(index: number, count: number): number { return Math.min(Math.max(index, FIRST_INDEX), count - 1); } -function wrapIndex(index: number, count: number): number { - if (count <= EMPTY_LENGTH) { - return FIRST_INDEX; - } - return ((index % count) + count) % count; -} diff --git a/src/app/workspace/file-tree.ts b/src/app/workspace/file-tree.ts index 32f6b6aa..bb193929 100644 --- a/src/app/workspace/file-tree.ts +++ b/src/app/workspace/file-tree.ts @@ -10,7 +10,7 @@ import type { EditorFilePort } from '../../ports/editor-file.js'; import type { GraftSessionPort } from '../../ports/graft-session.js'; import type { SourceHighlighter } from '../../ports/source-highlighter.js'; import { createNotificationTickCmd, pushErrorToast } from '../../ui/feedback.js'; -import { clampIndex } from './viewport.js'; +import { wrapIndex } from './viewport.js'; import { withFocusPane } from './focus.js'; import { isWorkspaceMarkdownFile } from './editor-session.js'; import { ViewModes } from './view-mode.js'; @@ -70,13 +70,13 @@ function updateTreeNavigationFromKey(msg: KeyMsg, model: WorkspaceModel): Worksp if (isWorkspaceDownKey(msg)) { return { ...model, - selectedIndex: clampIndex(model.selectedIndex + 1, model.entries.length), + selectedIndex: wrapIndex(model.selectedIndex + 1, model.entries.length), }; } if (isWorkspaceUpKey(msg)) { return { ...model, - selectedIndex: clampIndex(model.selectedIndex - 1, model.entries.length), + selectedIndex: wrapIndex(model.selectedIndex - 1, model.entries.length), }; } return undefined; diff --git a/src/app/workspace/focused-pane-key-bindings.ts b/src/app/workspace/focused-pane-key-bindings.ts index d8422fc6..10b84514 100644 --- a/src/app/workspace/focused-pane-key-bindings.ts +++ b/src/app/workspace/focused-pane-key-bindings.ts @@ -10,6 +10,17 @@ import type { WorkspaceMsg } from './msg.js'; type KeyBindingResult = [WorkspaceModel, Cmd[]]; +// A focused drawer owns its own navigation keys. Without this, the title +// screen's camera bindings claimed the arrow keys whenever no file was open, +// so arrows did nothing in the explorer and quietly switched on the +// ray-traced backdrop instead. +export function workspaceDrawerHasFocus(model: WorkspaceModel): boolean { + return ( + (model.focusPane === FocusPanes.Files && model.fileDrawerOpen) || + (model.focusPane === FocusPanes.Graft && model.graftDrawerOpen) + ); +} + export function updateFocusedPaneKey( msg: KeyMsg, model: WorkspaceModel, diff --git a/src/app/workspace/init.ts b/src/app/workspace/init.ts index f6df1e22..0ae7d7bd 100644 --- a/src/app/workspace/init.ts +++ b/src/app/workspace/init.ts @@ -7,6 +7,7 @@ import { } from "../../ui/title-scene.js"; import { TITLE_ASCII_PALETTE, + TITLE_BACKDROP_KIND, TITLE_RENDER_MODE, } from "../../ui/title-screen.js"; import { FocusPanes, type FocusPane } from "../../ui/panel-focus.js"; @@ -195,8 +196,21 @@ function initialSceneState( ...(sceneOverride == null ? {} : { sceneOverride }), titleCamera: createTitleCameraState(cameraPlacement), titleMouseLook: undefined, + titleBackdropKind: TITLE_BACKDROP_KIND.StaticLogo, titleRenderMode: TITLE_RENDER_MODE.Braille, titleAsciiPalette: TITLE_ASCII_PALETTE.Dense, titleMeshMaterialIndex: 0, }; } + +// Bijou re-renders only when update returns a different model reference, so an +// idle tick must return the model it was given. Rebuilding it on every pulse +// cost a full-surface render 60 times a second to produce identical output. +export function workspaceAnimationIsActive(model: WorkspaceModel): boolean { + return ( + !model.startupIntroComplete || + model.titleBackdropKind === TITLE_BACKDROP_KIND.LegacyScene || + model.profiler.active || + model.perfVisible + ); +} diff --git a/src/app/workspace/model.ts b/src/app/workspace/model.ts index 555d6a0d..708e19a4 100644 --- a/src/app/workspace/model.ts +++ b/src/app/workspace/model.ts @@ -12,6 +12,7 @@ import type { TitleMeshLibrary } from "../../ui/title-mesh-library.js"; import type { TitleScene } from "../../ui/title-scene.js"; import type { TitleAsciiPalette, + TitleBackdropKind, TitleRenderMode, } from "../../ui/title-screen.js"; import type { ProfilerState } from "../raytracer-profiler.js"; @@ -92,6 +93,7 @@ export interface WorkspaceModel readonly frameTimeHistory: readonly number[]; readonly titleCamera: TitleCameraState; readonly titleMouseLook?: TitleCameraMouseLookPointer; + readonly titleBackdropKind: TitleBackdropKind; readonly titleRenderMode: TitleRenderMode; readonly titleAsciiPalette: TitleAsciiPalette; readonly titleMeshMaterialIndex: number; diff --git a/src/app/workspace/mouse.ts b/src/app/workspace/mouse.ts index 7f9633d1..0309bba4 100644 --- a/src/app/workspace/mouse.ts +++ b/src/app/workspace/mouse.ts @@ -12,14 +12,44 @@ import { editorViewport } from './editor-session.js'; import { settingsRows } from './settings.js'; import type { SourceHighlighter } from '../../ports/source-highlighter.js'; import { ViewModes } from './view-mode.js'; +import { resolveWorkspaceLayout } from '../../ui/drawer-layout.js'; +import { + DRAWER_INNER_PAD, + listScrollOffset, + workspaceBodyHeight, + WORKSPACE_BODY_TOP_OFFSET, +} from './viewport.js'; import { FocusPanes } from '../../ui/panel-focus.js'; +import { workspaceDrawerHasFocus } from './focused-pane-key-bindings.js'; import { beginWorkspaceSourceHighlightRefresh } from './workspace-source-highlight.js'; +import { TITLE_BACKDROP_KIND } from '../../ui/title-screen.js'; + +const DRAWER_PAD_MULTIPLIER = 2; +const MOUSE_PRESS = 'press'; +const MOUSE_BUTTON_LEFT = 'left'; + +// Opening on click needs the filesystem and Echo dependencies the pointer path +// does not otherwise carry. Injecting a named opener keeps that dependency +// explicit and lets the selection-only behaviour stand on its own when the +// caller has nothing to open with. +export interface WorkspaceEntryOpener { + readonly nowMs: () => number; + readonly openEntry: ( + model: WorkspaceModel, + entry: NonNullable, + ) => [WorkspaceModel, Cmd[]]; +} export function updateFromMouse( msg: MouseMsg, model: WorkspaceModel, sourceHighlighter: SourceHighlighter, + opener?: WorkspaceEntryOpener, ): [WorkspaceModel, Cmd[]] { + const clicked = updateFileDrawerFromClick(msg, model); + if (clicked != null) { + return openClickedEntry(clicked, opener); + } const deltaRows = mouseScrollDeltaRows(msg); if (deltaRows === 0) { return updateTitleCameraFromMouse(msg, model); @@ -31,6 +61,68 @@ export function updateFromMouse( return updateEditorFromMouse(model, deltaRows, sourceHighlighter); } +// Row and column map onto the list the renderer drew: the drawer is blitted at +// (layout.fileDrawer.x, WORKSPACE_BODY_TOP_OFFSET) and its content is inset by +// DRAWER_INNER_PAD. Entry index equals line index because the list does not +// scroll -- long directories are truncated rather than paged today. +function updateFileDrawerFromClick( + msg: MouseMsg, + model: WorkspaceModel, +): WorkspaceModel | undefined { + if (msg.action !== MOUSE_PRESS || msg.button !== MOUSE_BUTTON_LEFT) { + return undefined; + } + const index = fileDrawerEntryIndexAt(msg, model); + return index == null + ? undefined + : { ...model, focusPane: FocusPanes.Files, selectedIndex: index }; +} + +function fileDrawerEntryIndexAt( + msg: MouseMsg, + model: WorkspaceModel, +): number | undefined { + if (!model.fileDrawerOpen) { + return undefined; + } + const drawer = resolveWorkspaceLayout( + model.columns, + model.fileDrawerProgress, + model.graftDrawerProgress, + ).fileDrawer; + const withinDrawer = drawer.width > 0 + && msg.col >= drawer.x + DRAWER_INNER_PAD + && msg.col < (drawer.x + drawer.width) - DRAWER_INNER_PAD; + if (!withinDrawer) { + return undefined; + } + const row = msg.row - WORKSPACE_BODY_TOP_OFFSET - DRAWER_INNER_PAD; + const listHeight = workspaceBodyHeight({ + rows: model.rows, + footerVisible: model.footerVisible, + }) - (DRAWER_INNER_PAD * DRAWER_PAD_MULTIPLIER); + if (row < 0 || row >= listHeight) { + return undefined; + } + const index = row + listScrollOffset( + model.selectedIndex, + model.entries.length, + listHeight, + ); + return index < model.entries.length ? index : undefined; +} + +function openClickedEntry( + model: WorkspaceModel, + opener: WorkspaceEntryOpener | undefined, +): [WorkspaceModel, Cmd[]] { + const entry = model.entries[model.selectedIndex]; + if (opener == null || entry == null) { + return [model, []]; + } + return opener.openEntry(model, entry); +} + function updateTitleCameraFromMouse( msg: MouseMsg, model: WorkspaceModel, @@ -48,14 +140,19 @@ function updateTitleCameraFromMouse( ...model, titleCamera: result.state, titleMouseLook: result.pointer, + titleBackdropKind: TITLE_BACKDROP_KIND.LegacyScene, }, [], ]; } +// Mouse-look activates the ray-traced backdrop, so it must not trigger while a +// drawer holds focus. Moving the pointer over the file explorer used to switch +// the ray tracer on without the reader asking for it. function titleMouseLookEnabled(model: WorkspaceModel): boolean { return ( model.editor == null && + !workspaceDrawerHasFocus(model) && !model.settingsOpen && !model.scenePickerOpen && !model.startupFileModalOpen && diff --git a/src/app/workspace/runtime.ts b/src/app/workspace/runtime.ts index 31bd213c..1f130e19 100644 --- a/src/app/workspace/runtime.ts +++ b/src/app/workspace/runtime.ts @@ -1,4 +1,4 @@ -import { createInitialModel } from "./init.js"; +import { createInitialModel, workspaceAnimationIsActive } from "./init.js"; import type { WorkspaceModel } from "./model.js"; import { applyNotificationState, @@ -10,7 +10,6 @@ import { SOURCE_HIGHLIGHT_MESSAGE, } from "../source-highlight-session.js"; import { - createTitleCameraState, reduceTitleCameraMotion, TITLE_CAMERA_MESSAGE, } from "../title-camera-session.js"; @@ -40,6 +39,7 @@ import { applyStartupIntroTime, applyWorkspaceCausalLineChangeResult, applyWorkspaceTextMessage, + applyWorkspaceTitleSceneLoadResult, applyWorkspaceWhyRangeResult, syncActiveWorkspaceBufferRecord, } from "./workspace-state-reducers.js"; @@ -89,7 +89,31 @@ function updateWorkspaceRuntime( msg: WorkspaceRuntimeMsg, model: WorkspaceModel, ): WorkspaceRuntimeResult { - return syncWorkspaceRuntimeResult(updateWorkspaceRuntimeState(deps, msg, model)); + return syncWorkspaceRuntimeResult( + rebaseFrameClock(deps, model, updateWorkspaceRuntimeState(deps, msg, model)), + ); +} + +// While the workspace is idle the tick handler returns the model untouched, so +// lastFrameMs stops advancing along with everything else. Whatever switches +// animation back on -- the perf overlay, the profiler, the legacy backdrop -- +// would otherwise hand the first active frame the whole idle interval as its +// duration: instantly over budget, which trips the backdrop's low-rate flag and +// leaves the animation frozen from then on. +// +// The baseline is reset on the inactive-to-active edge rather than on every +// idle tick, because advancing it during idle would mean returning a new model +// and defeating the render gate that made the workspace idle in the first place. +function rebaseFrameClock( + deps: WorkspaceRuntimeDependencies, + previous: WorkspaceModel, + result: WorkspaceRuntimeResult, +): WorkspaceRuntimeResult { + const [next, commands] = result; + if (workspaceAnimationIsActive(previous) || !workspaceAnimationIsActive(next)) { + return result; + } + return [{ ...next, lastFrameMs: deps.nowMs() }, commands]; } function updateWorkspaceRuntimeState( @@ -219,7 +243,7 @@ function updateGeneratedStateMessage( return applyWorkspaceCausalLineChangeResult(msg, model); } if (msg.type === WorkspaceMessageTypes.LoadSceneResult) { - return [applySceneLoadResult(model, msg), []]; + return [applyWorkspaceTitleSceneLoadResult(model, msg), []]; } if (msg.type === SOURCE_HIGHLIGHT_MESSAGE) { return [reduceSourceHighlightMsg(model, msg), []]; @@ -258,24 +282,6 @@ function isWorkspaceMsg(msg: WorkspaceRuntimeMsg): msg is WorkspaceMsg { ); } -function applySceneLoadResult( - model: WorkspaceModel, - msg: Extract< - WorkspaceMsg, - { type: typeof WorkspaceMessageTypes.LoadSceneResult } - >, -): WorkspaceModel { - return { - ...model, - sceneOverride: msg.scene, - titleSceneName: msg.scene == null ? undefined : msg.sceneName, - titleCamera: - msg.scene == null - ? model.titleCamera - : createTitleCameraState(msg.scene.camera), - }; -} - function updateWorkspaceEffectMessage( deps: WorkspaceRuntimeDependencies, msg: WorkspaceRuntimeMsg, @@ -300,11 +306,15 @@ function updateWorkspaceEffectMessage( return updateProfilerOrIssueMessage(deps, msg, model); } + function updateTimeTickMessage( deps: WorkspaceRuntimeDependencies, time: number, model: WorkspaceModel, ): WorkspaceRuntimeResult { + if (!workspaceAnimationIsActive(model)) { + return [model, []]; + } const now = deps.nowMs(); const frameTime = now - model.lastFrameMs; const nextModel = applyStartupIntroTime({ @@ -402,7 +412,14 @@ function updateWorkspaceInputMessage( model: WorkspaceModel, ): WorkspaceRuntimeResult { if (msg.type === WorkspaceInputMessageTypes.Mouse) { - return updateFromMouse(msg, model, deps.sourceHighlighter); + return updateFromMouse( + msg, + model, + deps.sourceHighlighter, + deps.openEntry == null + ? undefined + : { nowMs: deps.nowMs, openEntry: deps.openEntry }, + ); } return msg.type === WorkspaceInputMessageTypes.Key ? updateFromKey(msg, model, workspaceKeyDeps(deps)) diff --git a/src/app/workspace/scene-picker-key-bindings.ts b/src/app/workspace/scene-picker-key-bindings.ts index 64f746f5..1c763884 100644 --- a/src/app/workspace/scene-picker-key-bindings.ts +++ b/src/app/workspace/scene-picker-key-bindings.ts @@ -15,6 +15,7 @@ import { isWorkspaceScenePickerPreviousKey, } from "./workspace-key.js"; import type { WorkspaceKeyBindingContext } from "./key-binding-context.js"; +import { TITLE_BACKDROP_KIND } from "../../ui/title-screen.js"; const SCENE_PICKER_MIN_INDEX = 0; const SCENE_PICKER_STEP = 1; @@ -33,7 +34,14 @@ export function updateScenePickerKey( !msg.alt && msg.key === JEDIT_SCENE_PICKER_TOGGLE_KEY ) { - return [{ ...model, scenePickerOpen: !model.scenePickerOpen }, []]; + return [ + { + ...model, + scenePickerOpen: !model.scenePickerOpen, + titleBackdropKind: TITLE_BACKDROP_KIND.LegacyScene, + }, + [], + ]; } if (!model.scenePickerOpen) { diff --git a/src/app/workspace/title-scene-performance-governor.ts b/src/app/workspace/title-scene-performance-governor.ts index fc380f93..39a14035 100644 --- a/src/app/workspace/title-scene-performance-governor.ts +++ b/src/app/workspace/title-scene-performance-governor.ts @@ -1,5 +1,6 @@ const LIVE_TRACE = "live-trace"; const LOW_RATE_FROZEN_BACKDROP = "low-rate-frozen-backdrop"; +const STATIC_TITLE = "static-title"; const ANIMATED_TITLE_INPUT = "animated-title"; const LOW_RATE_INPUT = "low-rate-title"; const WITHIN_BUDGET = "within-budget"; @@ -11,15 +12,18 @@ export const TITLE_SCENE_LOW_RATE_REFRESH_SECONDS = 0.5; export const TITLE_SCENE_RENDER_POSTURE = Object.freeze({ LiveTrace: LIVE_TRACE, LowRateFrozenBackdrop: LOW_RATE_FROZEN_BACKDROP, + StaticTitle: STATIC_TITLE, }); export type TitleSceneRenderPosture = | typeof LIVE_TRACE - | typeof LOW_RATE_FROZEN_BACKDROP; + | typeof LOW_RATE_FROZEN_BACKDROP + | typeof STATIC_TITLE; export type TitleSceneInputLatencyPosture = | typeof ANIMATED_TITLE_INPUT - | typeof LOW_RATE_INPUT; + | typeof LOW_RATE_INPUT + | typeof STATIC_TITLE; export type TitleSceneFrameBudgetPosture = | typeof WITHIN_BUDGET @@ -80,6 +84,17 @@ export function titleScenePerformanceFacts( }; } +export function staticTitleScenePerformanceFacts(): TitleScenePerformanceFacts { + return { + posture: TITLE_SCENE_RENDER_POSTURE.StaticTitle, + tracesRays: false, + usesFrozenBackdrop: false, + retainsBackdrop: false, + inputLatencyPosture: STATIC_TITLE, + frameBudgetPosture: WITHIN_BUDGET, + }; +} + function liveTraceDecision( frameBudgetPosture: TitleSceneFrameBudgetPosture, ): TitleSceneRenderDecision { diff --git a/src/app/workspace/title-scene-performance-stats.ts b/src/app/workspace/title-scene-performance-stats.ts index 70f5b7ae..0266040e 100644 --- a/src/app/workspace/title-scene-performance-stats.ts +++ b/src/app/workspace/title-scene-performance-stats.ts @@ -5,7 +5,7 @@ import { } from "../../ui/title-scene.js"; import type { TitleScene, TitleSceneObject } from "../../ui/title-scene.js"; import { titleSceneRenderMaterialColors } from "../../ui/title-screen.js"; -import { TITLE_RENDER_MODE } from "../../ui/title-screen.js"; +import { TITLE_BACKDROP_KIND, TITLE_RENDER_MODE } from "../../ui/title-screen.js"; import { ASCII_SAMPLE_COUNT } from "../../ui/averaging-ascii-canvas.js"; import { BRAILLE_SAMPLE_COUNT } from "../../ui/averaging-braille-canvas.js"; import type { WorkspaceModel } from "./model.js"; @@ -32,6 +32,12 @@ export function titleScenePerformanceStats( if (model.editor != null) { return undefined; } + if (model.titleBackdropKind !== TITLE_BACKDROP_KIND.LegacyScene) { + // Nothing is traced unless the legacy backdrop is selected. Reporting a + // generated scene's object, triangle and ray counts for a backdrop that + // was never drawn made the overlay claim rays the renderer never cast. + return undefined; + } const scene = titleSceneForModel(model); const dimensions = titleRenderDimensions(model); return { diff --git a/src/app/workspace/title-screen-key-bindings.ts b/src/app/workspace/title-screen-key-bindings.ts index 9b95a34e..2cb626fa 100644 --- a/src/app/workspace/title-screen-key-bindings.ts +++ b/src/app/workspace/title-screen-key-bindings.ts @@ -9,6 +9,7 @@ import { import { nextTitleAsciiPalette, TITLE_ASCII_PALETTE, + TITLE_BACKDROP_KIND, TITLE_RENDER_MODE, type TitleAsciiPalette, } from "../../ui/title-screen.js"; @@ -19,12 +20,15 @@ import { titleMeshMaterialPresetAt, } from "./title-mesh-materials.js"; import type { WorkspaceKeyBindingContext } from "./key-binding-context.js"; +import { workspaceDrawerHasFocus } from './focused-pane-key-bindings.js'; import type { WorkspaceModel } from "./model.js"; import type { WorkspaceMsg } from "./msg.js"; import { WorkspaceKeys } from "./workspace-key.js"; const TITLE_SHADER_TOAST_TITLE = "Title shader"; const TITLE_ASCII_PALETTE_TOAST_TITLE = "ASCII palette"; +const TITLE_MESH_MATERIAL_NEEDS_SCENE = + "Load a scene first (ctrl+l) -- the generated backdrop takes its materials from the theme."; const TITLE_MESH_MATERIAL_TOAST_TITLE = "Title material"; const TITLE_SHADER_BRAILLE_LABEL = "Braille"; const TITLE_SHADER_ASCII_LABEL = "ASCII"; @@ -55,7 +59,7 @@ export function updateTitleScreenKey( model: WorkspaceModel, context: WorkspaceKeyBindingContext, ): KeyBindingResult | undefined { - if (model.editor != null) { + if (model.editor != null || workspaceDrawerHasFocus(model)) { return undefined; } @@ -74,14 +78,20 @@ function updateTitleRenderKey( ): KeyBindingResult | undefined { if (msg.key === WorkspaceKeys.One) { return pushTitleScreenToast( - { ...model, titleRenderMode: TITLE_RENDER_MODE.Braille }, + activateLegacyTitleBackdrop({ + ...model, + titleRenderMode: TITLE_RENDER_MODE.Braille, + }), TITLE_SHADER_BRAILLE_LABEL, context, ); } if (msg.key === WorkspaceKeys.Two) { return pushTitleScreenToast( - { ...model, titleRenderMode: TITLE_RENDER_MODE.Ascii }, + activateLegacyTitleBackdrop({ + ...model, + titleRenderMode: TITLE_RENDER_MODE.Ascii, + }), asciiShaderLabel(model), context, ); @@ -95,7 +105,7 @@ function updateTitleRenderKey( const titleAsciiPalette = nextTitleAsciiPalette(model.titleAsciiPalette); return pushAsciiPaletteToast( - { ...model, titleAsciiPalette }, + activateLegacyTitleBackdrop({ ...model, titleAsciiPalette }), titleAsciiPalette, context, ); @@ -113,16 +123,20 @@ function updateTitleMeshMaterialKey( model.titleMeshMaterialIndex, ); const preset = titleMeshMaterialPresetAt(titleMeshMaterialIndex); - const sceneOverride = - model.sceneOverride == null - ? undefined - : applyTitleMeshMaterial(model.sceneOverride, preset); + // With no scene loaded there is nothing to apply a material to: the generated + // backdrop takes its materials from the theme and never reads + // titleMeshMaterialIndex. Cycling the index anyway used to switch the legacy + // backdrop on and toast that the preset had been applied, so the reader was + // told a change had happened that could not have happened. + if (model.sceneOverride == null) { + return pushTitleMeshMaterialToast(model, TITLE_MESH_MATERIAL_NEEDS_SCENE, context); + } return pushTitleMeshMaterialToast( - { + activateLegacyTitleBackdrop({ ...model, titleMeshMaterialIndex, - ...(sceneOverride == null ? {} : { sceneOverride }), - }, + sceneOverride: applyTitleMeshMaterial(model.sceneOverride, preset), + }), preset.name, context, ); @@ -146,7 +160,17 @@ function updateTitleCameraKey( }); return update == null ? undefined - : [{ ...model, titleCamera: update.state }, update.commands]; + : [ + activateLegacyTitleBackdrop({ ...model, titleCamera: update.state }), + update.commands, + ]; +} + +function activateLegacyTitleBackdrop(model: WorkspaceModel): WorkspaceModel { + return { + ...model, + titleBackdropKind: TITLE_BACKDROP_KIND.LegacyScene, + }; } function pushTitleScreenToast( diff --git a/src/app/workspace/viewer-content.ts b/src/app/workspace/viewer-content.ts index 0f07a44c..576e90bd 100644 --- a/src/app/workspace/viewer-content.ts +++ b/src/app/workspace/viewer-content.ts @@ -2,6 +2,7 @@ import { createSurface, type Surface } from "@flyingrobots/bijou"; import { paintMarkdownPreview } from "../../ui/markdown-preview.js"; import { renderSourceViewer } from "../../ui/source-viewer.js"; import { + TITLE_BACKDROP_KIND, TITLE_RENDER_MODE, paintTitleScreenPresentation, renderTitleScreen, @@ -30,8 +31,8 @@ import { import { fillSurface } from "./surface-fill.js"; import { governTitleSceneRender, + staticTitleScenePerformanceFacts, titleScenePerformanceFacts, - TITLE_SCENE_RENDER_POSTURE, type TitleScenePerformanceFacts, } from "./title-scene-performance-governor.js"; import { @@ -44,14 +45,6 @@ const MIN_VIEWPORT_DIMENSION = 1; const VIEWER_PAD_MULTIPLIER = 2; const TITLE_CAMERA_MOTION_EPSILON = 0.001; const TITLE_FRAME_BUDGET_OVER = "over-budget"; -const INITIAL_TITLE_SCENE_PERFORMANCE_FACTS: TitleScenePerformanceFacts = { - posture: TITLE_SCENE_RENDER_POSTURE.LiveTrace, - tracesRays: true, - usesFrozenBackdrop: false, - retainsBackdrop: true, - inputLatencyPosture: "animated-title", - frameBudgetPosture: "within-budget", -}; interface FrozenTitleBackdrop { readonly width: number; @@ -96,7 +89,7 @@ export interface ViewerContentRenderer { } export function createViewerContentRenderer( - titleRenderer: TitleScreenRenderer = renderTitleScreen, + titleRenderer?: TitleScreenRenderer, ): ViewerContentRenderer { const state: ViewerContentRendererState = {}; return { @@ -108,7 +101,7 @@ export function createViewerContentRenderer( }, titleScenePerformanceFacts() { return ( - state.lastTitleScenePerformance ?? INITIAL_TITLE_SCENE_PERFORMANCE_FACTS + state.lastTitleScenePerformance ?? staticTitleScenePerformanceFacts() ); }, }; @@ -139,12 +132,19 @@ function renderViewerWithState( model: WorkspaceModel, width: number, height: number, - titleRenderer: TitleScreenRenderer, + titleRenderer: TitleScreenRenderer | undefined, state: ViewerContentRendererState, ): Surface { const editor = displayEditorForWorkspaceModel(model); if (editor == null) { - return renderTitleBackdrop(model, width, height, titleRenderer, state); + // jedit opens the way vi does: on nothing. The ray-traced backdrop is + // still reachable -- the scene picker and the title number keys set + // LegacyScene, and callers may inject a renderer directly -- but no + // launch selects it, so startup renders an empty viewer. + return model.titleBackdropKind === TITLE_BACKDROP_KIND.LegacyScene || + titleRenderer != null + ? renderTitleViewer(model, width, height, titleRenderer, state) + : emptyViewerSurface(model, width, height); } const surface = createSurface(width, height); @@ -176,6 +176,51 @@ function renderViewerWithState( ); } +function emptyViewerSurface( + model: WorkspaceModel, + width: number, + height: number, +): Surface { + const surface = createSurface(width, height); + fillSurface(surface, model.jeditTheme.surface.workspace); + return surface; +} + +function renderTitleViewer( + model: WorkspaceModel, + width: number, + height: number, + injectedRenderer: TitleScreenRenderer | undefined, + state: ViewerContentRendererState, +): Surface { + const renderer = + injectedRenderer ?? + (model.titleBackdropKind === TITLE_BACKDROP_KIND.LegacyScene + ? renderTitleScreen + : undefined); + return renderer == null + ? renderDefaultTitleFrame(model, width, height, state) + : renderTitleBackdrop(model, width, height, renderer, state); +} + +function renderDefaultTitleFrame( + model: WorkspaceModel, + width: number, + height: number, + state: ViewerContentRendererState, +): Surface { + // No backdrop is the default. The legacy ray-traced scene is the only + // title presentation jedit still draws, and only when a caller asks. + state.lastTitleScenePerformance = staticTitleScenePerformanceFacts(); + return titleFrameSurface( + emptyViewerSurface(model, width, height), + model, + width, + height, + state, + ); +} + function renderTitleBackdrop( model: WorkspaceModel, width: number, diff --git a/src/app/workspace/viewer-drawers.ts b/src/app/workspace/viewer-drawers.ts index b33a1014..5483dc56 100644 --- a/src/app/workspace/viewer-drawers.ts +++ b/src/app/workspace/viewer-drawers.ts @@ -6,7 +6,7 @@ import { formatTreeLine, } from '../../ui/workspace-render.js'; import type { WorkspaceModel } from './model.js'; -import { DRAWER_INNER_PAD } from './viewport.js'; +import { DRAWER_INNER_PAD, listScrollOffset } from './viewport.js'; import { applyBackground, fillSurface } from './surface-fill.js'; const MIN_VIEWPORT_DIMENSION = 1; @@ -21,9 +21,16 @@ export function renderDrawer(kind: DrawerKind, model: WorkspaceModel, width: num const listWidth = Math.max(MIN_VIEWPORT_DIMENSION, width - (DRAWER_INNER_PAD * DRAWER_PAD_MULTIPLIER)); const listHeight = Math.max(MIN_VIEWPORT_DIMENSION, height - (DRAWER_INNER_PAD * DRAWER_PAD_MULTIPLIER)); - const lines = model.entries.map((entry, index) => formatTreeLine(entry, { - selected: index === model.selectedIndex, - })); + const offset = listScrollOffset( + model.selectedIndex, + model.entries.length, + listHeight, + ); + const lines = model.entries + .slice(offset, offset + listHeight) + .map((entry, index) => formatTreeLine(entry, { + selected: index + offset === model.selectedIndex, + })); const content = stringToSurface(fitBlock(lines.join('\n'), listWidth, listHeight), listWidth, listHeight); applyBackground(content, model.jeditTheme.surface.drawer); surface.blit(content, DRAWER_INNER_PAD, DRAWER_INNER_PAD); diff --git a/src/app/workspace/viewer.ts b/src/app/workspace/viewer.ts index 411771cf..81b8cc9e 100644 --- a/src/app/workspace/viewer.ts +++ b/src/app/workspace/viewer.ts @@ -12,8 +12,7 @@ import { MIN_COLUMNS, MIN_ROWS, workspaceBodyHeight, - FOOTER_ROWS, -} from './viewport.js'; + FOOTER_ROWS, WORKSPACE_BODY_TOP_OFFSET } from './viewport.js'; import type { WorkspaceModel } from './model.js'; import { createViewerContentRenderer, @@ -30,7 +29,6 @@ import { } from './workspace-footer-posture.js'; import type { JeditColorStop, JeditStyleToken } from '../../ui/jedit-theme.js'; -const WORKSPACE_BODY_TOP_OFFSET = 2; const COMMAND_LINE_WARNING_VARIABLE = 'warning'; const COMMAND_LINE_ERROR_FALLBACK_BACKGROUND = '#6f1d1b'; const COMMAND_LINE_ERROR_FALLBACK_FOREGROUND = '#ffeef0'; @@ -90,7 +88,7 @@ function paintWorkspaceTitle(screen: Surface, model: WorkspaceModel): void { editorDirty: model.editor?.dirty ?? false, selectedEntry: model.entries[model.selectedIndex], }), model.columns), model.columns, 1); - applyTitleToken(title, model.jeditTheme.chrome.titleLogo); + applyTitleToken(title, model.jeditTheme.surface.header); screen.blit(title, 0, 0); } diff --git a/src/app/workspace/viewport.ts b/src/app/workspace/viewport.ts index 9d14b35e..b69fc0ea 100644 --- a/src/app/workspace/viewport.ts +++ b/src/app/workspace/viewport.ts @@ -1,3 +1,4 @@ +export { wrapIndex } from '../list-index.js'; import { resolveWorkspaceLayout } from '../../ui/drawer-layout.js'; import { sourceViewerGutterWidth } from '../../ui/source-viewer.js'; import type { WorkspaceModel } from './model.js'; @@ -7,6 +8,9 @@ export const MIN_ROWS = 12; export const VIEWER_LEFT_PAD = 4; export const VIEWER_TOP_PAD = 1; export const DRAWER_INNER_PAD = 1; +// Rows above the workspace body: the title row and its rule. Shared so pointer +// hit-testing and the renderer cannot disagree about where the body starts. +export const WORKSPACE_BODY_TOP_OFFSET = 2; export const HEADER_ROWS = 2; export const FOOTER_ROWS = 2; @@ -34,6 +38,22 @@ export function clampIndex(index: number, size: number): number { return Math.max(0, Math.min(size - 1, index)); } +// The drawer list is windowed rather than truncated. Deriving the offset from +// the selection keeps it stateless, so the renderer and pointer hit-testing +// always agree on which entry a row shows without a scroll field in the model. +export function listScrollOffset( + selectedIndex: number, + total: number, + height: number, +): number { + const visibleRows = Math.max(1, height); + if (total <= visibleRows) { + return 0; + } + const centred = selectedIndex - Math.floor(visibleRows / 2); + return Math.max(0, Math.min(total - visibleRows, centred)); +} + export function workspaceBodyHeight(options: WorkspaceBodyHeightOptions): number { const footerRows = options.footerVisible ? FOOTER_ROWS : 0; return Math.max(1, options.rows - HEADER_ROWS - footerRows); diff --git a/src/app/workspace/workspace-runtime-dependencies.ts b/src/app/workspace/workspace-runtime-dependencies.ts index 7fc44604..7ce31018 100644 --- a/src/app/workspace/workspace-runtime-dependencies.ts +++ b/src/app/workspace/workspace-runtime-dependencies.ts @@ -13,12 +13,17 @@ import type { WorkspaceMsg } from './msg.js'; import type { ProductionTextSession } from './production-text-session.js'; import type { ProfilerTracePort } from '../raytracer-profiler.js'; import type { renderWorkspace } from './viewer.js'; +import type { WorkspaceEntryOpener } from './mouse.js'; export type WorkspaceRuntimeMsg = WorkspaceMsg | ResizeMsg | KeyMsg | MouseMsg; export type WorkspaceRuntimeResult = [WorkspaceModel, Cmd[]]; export type WorkspaceResizeMsg = ResizeMsg; export interface WorkspaceRuntimeDependencies { + // Supplied by the composition root so pointer clicks can open an entry. + // Optional: the pointer path still selects without it, and test harnesses + // that never click do not have to construct the filesystem dependencies. + readonly openEntry?: WorkspaceEntryOpener['openEntry']; readonly initialColumns: number; readonly initialRows: number; readonly initialWorkingDirectory: string; diff --git a/src/app/workspace/workspace-state-reducers.ts b/src/app/workspace/workspace-state-reducers.ts index 034c463e..b5b66220 100644 --- a/src/app/workspace/workspace-state-reducers.ts +++ b/src/app/workspace/workspace-state-reducers.ts @@ -9,3 +9,4 @@ export { applyWorkspaceTextMessage } from './workspace-text-message-reducer.js'; export { applyWorkspaceCausalLineChangeResult } from './workspace-causal-line-change-refresh.js'; export { syncActiveWorkspaceBufferRecord } from './workspace-buffer-registry.js'; export { applyWorkspaceWhyRangeResult } from './workspace-why-range.js'; +export { applyWorkspaceTitleSceneLoadResult } from './workspace-title-scene-state.js'; diff --git a/src/app/workspace/workspace-title-scene-state.ts b/src/app/workspace/workspace-title-scene-state.ts new file mode 100644 index 00000000..d14f1dbf --- /dev/null +++ b/src/app/workspace/workspace-title-scene-state.ts @@ -0,0 +1,29 @@ +import { createTitleCameraState } from '../title-camera-session.js'; +import { TITLE_BACKDROP_KIND } from '../../ui/title-screen.js'; +import type { WorkspaceModel } from './model.js'; +import { WorkspaceMessageTypes, type WorkspaceMsg } from './msg.js'; + +export function applyWorkspaceTitleSceneLoadResult( + model: WorkspaceModel, + msg: Extract< + WorkspaceMsg, + { type: typeof WorkspaceMessageTypes.LoadSceneResult } + >, +): WorkspaceModel { + // A result carrying no scene is a cancellation or a RuntimeIssue, not a + // backdrop change. Switching to LegacyScene regardless dropped the static + // logo for a scene that was never loaded, and because + // workspaceAnimationIsActive counts LegacyScene as animating, it also put the + // workspace back into a 60Hz render loop -- the #320 regression, on the + // failure path. + if (msg.scene == null) { + return model; + } + return { + ...model, + sceneOverride: msg.scene, + titleSceneName: msg.sceneName, + titleBackdropKind: TITLE_BACKDROP_KIND.LegacyScene, + titleCamera: createTitleCameraState(msg.scene.camera), + }; +} diff --git a/src/main-workspace.ts b/src/main-workspace.ts index 326f2d15..0875cf2b 100644 --- a/src/main-workspace.ts +++ b/src/main-workspace.ts @@ -1,7 +1,7 @@ import { initDefaultContext } from '@flyingrobots/bijou-node'; import { run } from '@flyingrobots/bijou-tui'; import { createWorkspaceApp } from './adapters/workspace-app.js'; -import { createWorkspaceProductionTextDependencies } from './adapters/workspace-production-text-dependencies.js'; +import { closingProductionText, createWorkspaceProductionTextDependencies } from './adapters/workspace-production-text-dependencies.js'; import { parseTextRuntimeProfile, requireTextRuntimeProfile } from './app/text-runtime-profile.js'; import { JEDIT_TERMINAL_MOUSE_OPTIONS } from './ui/terminal-mouse.js'; @@ -26,6 +26,29 @@ interface EnvBooleanOptions { readonly defaultValue: boolean; } +export const WORKSPACE_INSTRUMENTATION_ENV = Object.freeze({ + Perf: ENV_KEYS.Perf, + Profile: ENV_KEYS.Profile, +}); + +export interface WorkspaceInstrumentation { + readonly perfEnabled: boolean; + readonly profileEnabled: boolean; +} + +// Both default off. The perf overlay rebuilds three surfaces every frame, and +// the profiler opens .jedit/perf-session.jsonl and appends a frame record per +// tick -- a measured 268 KB of disk writes over a 22 second idle session. +// Neither belongs on a launch nobody asked to instrument. +export function workspaceInstrumentationFromEnv( + env: Readonly>, +): WorkspaceInstrumentation { + return { + perfEnabled: envBoolean(env[ENV_KEYS.Perf], { defaultValue: false }), + profileEnabled: envBoolean(env[ENV_KEYS.Profile], { defaultValue: false }), + }; +} + export async function runJeditWorkspace(): Promise { requireTextRuntimeProfile(parseTextRuntimeProfile( process.env[ENV_KEYS.TextRuntime], @@ -35,17 +58,22 @@ export async function runJeditWorkspace(): Promise { const productionText = await createWorkspaceProductionTextDependencies(); - const app = createWorkspaceApp({ - initialColumns: process.stdout.columns ?? DEFAULT_TERMINAL_COLUMNS, - initialRows: process.stdout.rows ?? DEFAULT_TERMINAL_ROWS, - initialWorkingDirectory: DEFAULT_WORKING_DIRECTORY, - perfEnabled: envBoolean(process.env[ENV_KEYS.Perf], { defaultValue: true }), - profileEnabled: envBoolean(process.env[ENV_KEYS.Profile], { - defaultValue: true, - }), - }, productionText); - - run(app, { mouse: JEDIT_TERMINAL_MOUSE_OPTIONS.mouse }); + // `run` resolves when the TUI tears down. It was not awaited, so this + // function returned while the editor was still live and the shutdown below + // could never happen. Closing the native Echo host releases the child stdio + // pipes that were holding the event loop open after quit. + // + // App construction is inside the guard, not before it: it can throw, and when + // it did the host was left open and the terminal unrestored. + await closingProductionText(productionText, async () => { + const app = createWorkspaceApp({ + initialColumns: process.stdout.columns ?? DEFAULT_TERMINAL_COLUMNS, + initialRows: process.stdout.rows ?? DEFAULT_TERMINAL_ROWS, + initialWorkingDirectory: DEFAULT_WORKING_DIRECTORY, + ...workspaceInstrumentationFromEnv(process.env), + }, productionText); + await run(app, { mouse: JEDIT_TERMINAL_MOUSE_OPTIONS.mouse }); + }); } function envBoolean( diff --git a/src/ui/jedit-theme-palettes.ts b/src/ui/jedit-theme-palettes.ts index ac37c85f..c3081d30 100644 --- a/src/ui/jedit-theme-palettes.ts +++ b/src/ui/jedit-theme-palettes.ts @@ -103,15 +103,23 @@ const SOLARIZED_DARK_PALETTE: ThemePalette = { }; const SOLARIZED_LIGHT_PALETTE: ThemePalette = { - ink: [101, 123, 131], + // Solarized base01 rather than base00. Upstream Solarized specifies base00 + // for light-mode body text, which measures 4.13:1 on base3 -- below WCAG AA + // and reported as unreadable in use. base01 is a canonical Solarized colour + // (its "emphasized content" ink) and reaches 4.99:1 without leaving the + // palette. + ink: [88, 110, 117], muted: [147, 161, 161], accent: [211, 54, 130], info: [38, 139, 210], warning: [181, 137, 0], success: [133, 153, 0], surface: [253, 246, 227], - surfaceRaised: [238, 232, 213], - surfaceMuted: [238, 232, 213], + // Solarized base2 (238, 232, 213) leaves the current-line band at 4.39:1 + // against base01 ink. Lifted 20% toward base3, which is the smallest change + // that reaches AA while still reading as a distinct tinted highlight. + surfaceRaised: [241, 235, 216], + surfaceMuted: [241, 235, 216], }; const DRACULA_PALETTE: ThemePalette = { diff --git a/src/ui/jedit-theme.ts b/src/ui/jedit-theme.ts index 978d6843..788f085b 100644 --- a/src/ui/jedit-theme.ts +++ b/src/ui/jedit-theme.ts @@ -131,7 +131,9 @@ export interface JeditStyleToken extends Pick= options.topPad + options.viewport.height) { + return; + } + const token = options.theme.surface.currentLine; + const endX = Math.min(surface.width, options.leftPad + options.viewport.width); + for (let x = Math.max(0, options.leftPad); x < endX; x += 1) { + const cell = surface.get(x, y); + surface.set(x, y, { + ...cell, + bg: token.bg, + bgRGB: token.bgRGB, + empty: false, + }); + } +} + function paintSourceViewerCursor( surface: Surface, editor: SourceViewerEditor, @@ -290,10 +321,10 @@ function sourceLineNumberLabel( if (mode === SOURCE_LINE_NUMBER_MODE.Absolute) { return String(lineNumber + FIRST_VISIBLE_LINE_NUMBER); } - const relative = lineNumber - cursorRow; - return relative > CURRENT_LINE_RELATIVE_NUMBER - ? `+${relative}` - : String(relative); + if (lineNumber === cursorRow) { + return String(lineNumber + FIRST_VISIBLE_LINE_NUMBER); + } + return String(Math.abs(lineNumber - cursorRow)); } function lineNumberLabelWidth( @@ -309,13 +340,7 @@ function lineNumberLabelWidth( Math.abs(cursorRow), Math.abs(totalLineCount - FIRST_VISIBLE_LINE_NUMBER - cursorRow), ); - return Math.max(absoluteWidth, signedLineNumberWidth(maxRelative)); -} - -function signedLineNumberWidth(value: number): number { - return value <= CURRENT_LINE_RELATIVE_NUMBER - ? String(CURRENT_LINE_RELATIVE_NUMBER).length - : String(value).length + SIGN_CHARACTER_WIDTH; + return Math.max(absoluteWidth, String(maxRelative).length); } function paintGutterRuleGap( diff --git a/src/ui/theme-builder.ts b/src/ui/theme-builder.ts index eebd8539..08e8523b 100644 --- a/src/ui/theme-builder.ts +++ b/src/ui/theme-builder.ts @@ -166,11 +166,10 @@ export interface JeditMarkdownStyleDrafts { readonly rule: JeditStyleDraft; } -export interface JeditSurfaceStyleDrafts { - readonly workspace: JeditStyleDraft; - readonly drawer: JeditStyleDraft; - readonly footer: JeditStyleDraft; -} +export type JeditSurfaceStyleDrafts = Readonly>; export interface JeditCursorStyleDrafts { readonly normal: JeditStyleDraft; @@ -273,7 +272,9 @@ function buildGutterVariants(draft: JeditThemeDraft): JeditTheme['gutter'] { function buildSurfaceTokens(draft: JeditThemeDraft): JeditTheme['surface'] { return { workspace: styleTokenFromDraft(draft.surface.workspace), + currentLine: styleTokenFromDraft(draft.surface.currentLine), drawer: styleTokenFromDraft(draft.surface.drawer), + header: styleTokenFromDraft(draft.surface.header), footer: styleTokenFromDraft(draft.surface.footer), }; } @@ -298,7 +299,7 @@ function createThemeDraft(variables: Map): JeditThemeDra style: JEDIT_TEXT_MODIFIER, source: createSourceDrafts(), markdown: createMarkdownDrafts(), - surface: { workspace: {}, drawer: {}, footer: {} }, + surface: { workspace: {}, currentLine: {}, drawer: {}, header: {}, footer: {} }, cursor: { normal: {}, insert: {} }, chrome: createChromeDrafts(), gutter: createGutterVariantDrafts(), diff --git a/src/ui/title-backdrop-kind.ts b/src/ui/title-backdrop-kind.ts new file mode 100644 index 00000000..5ca74adf --- /dev/null +++ b/src/ui/title-backdrop-kind.ts @@ -0,0 +1,10 @@ +const STATIC_LOGO = 'static-logo'; +const LEGACY_SCENE = 'legacy-scene'; + +export const TITLE_BACKDROP_KIND = Object.freeze({ + StaticLogo: STATIC_LOGO, + LegacyScene: LEGACY_SCENE, +}); + +export type TitleBackdropKind = + (typeof TITLE_BACKDROP_KIND)[keyof typeof TITLE_BACKDROP_KIND]; diff --git a/src/ui/title-screen.ts b/src/ui/title-screen.ts index 3ff7f6f4..6bb7e2b8 100644 --- a/src/ui/title-screen.ts +++ b/src/ui/title-screen.ts @@ -83,6 +83,10 @@ export { type TitleSceneMaterialColors, } from "./title-scene-material-colors.js"; export { titleLogoCellBounds } from "./title-logo.js"; +export { + TITLE_BACKDROP_KIND, + type TitleBackdropKind, +} from "./title-backdrop-kind.js"; export { paintTitleScreenPresentation } from "./title-screen-presentation.js"; export { titleFloorLightEffectsAt } from "./title-screen-optics.js"; diff --git a/src/ui/workspace-chrome.ts b/src/ui/workspace-chrome.ts index fa8455ae..4a2b1ce3 100644 --- a/src/ui/workspace-chrome.ts +++ b/src/ui/workspace-chrome.ts @@ -50,7 +50,6 @@ const FooterHintKeys = Object.freeze({ TextInput: 'text_input', EscNormal: 'esc_normal', CtrlSSave: 'ctrl_s_save', - CtrlTTheme: 'ctrl_t_theme', TabFocus: 'tab_focus', TabIndent: 'tab_indent', CtrlLScenePicker: 'ctrl_l_scene_picker', @@ -240,7 +239,6 @@ function footerDetail(state: WorkspaceFooterState): string { ?? footerHints([ scenePickerHint(t), focusHint(state, t), - themeHint(t), t(FooterHintKeys.CtrlBFiles), t(FooterHintKeys.CtrlGGraft), ]); @@ -284,9 +282,6 @@ function scenePickerHint(t: FooterHintTranslator): string { return t(FooterHintKeys.CtrlLScenePicker); } -function themeHint(t: FooterHintTranslator): string { - return t(FooterHintKeys.CtrlTTheme); -} function drawerFooterDetail(state: WorkspaceFooterState, kind: DrawerKind, t: FooterHintTranslator): string { if (kind === DrawerKinds.Files) { @@ -303,7 +298,7 @@ function normalFooterDetail(state: WorkspaceFooterState, t: FooterHintTranslator } const previewHint = state.markdownPreviewActive ? t(FooterHintKeys.F3Preview) : t(FooterHintKeys.CtrlSSave); - return footerHints([state.commandSummary, t(FooterHintKeys.IInsert), t(FooterHintKeys.OOpenLine), previewHint, themeHint(t), focusHint(state, t)]); + return footerHints([state.commandSummary, t(FooterHintKeys.IInsert), t(FooterHintKeys.OOpenLine), previewHint, focusHint(state, t)]); } function pendingNormalFooterDetail(pending: PendingNormal, t: FooterHintTranslator): string { @@ -326,7 +321,6 @@ function previewFooterHints(state: WorkspaceFooterState, t: FooterHintTranslator return [ t(FooterHintKeys.JkScroll), t(FooterHintKeys.F3Source), - themeHint(t), focusHint(state, t), t(FooterHintKeys.CtrlBFiles), t(FooterHintKeys.CtrlGGraft), @@ -339,7 +333,6 @@ function fileDrawerFooterHints(state: WorkspaceFooterState, t: FooterHintTransla t(FooterHintKeys.EnterOpen), t(FooterHintKeys.BackspaceUp), t(FooterHintKeys.CtrlBClose), - themeHint(t), focusHint(state, t), ]; } @@ -350,13 +343,12 @@ function graftDrawerFooterHints(state: WorkspaceFooterState, t: FooterHintTransl t(FooterHintKeys.EnterJump), t(FooterHintKeys.RRefresh), t(FooterHintKeys.CtrlGClose), - themeHint(t), focusHint(state, t), ]; } function insertModeFooterHints(state: WorkspaceFooterState, t: FooterHintTranslator): ReadonlyArray { - return [t(FooterHintKeys.TextInput), t(FooterHintKeys.EscNormal), t(FooterHintKeys.CtrlSSave), themeHint(t), insertTabHint(state, t)]; + return [t(FooterHintKeys.TextInput), t(FooterHintKeys.EscNormal), t(FooterHintKeys.CtrlSSave), insertTabHint(state, t)]; } function changeFooterHints(t: FooterHintTranslator): readonly string[] {