diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a57f57b718..050a32c777 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -98,7 +98,7 @@ repos: - id: ruff-format - repo: https://github.com/cursorless-dev/talon-tools - rev: v0.9.0 + rev: v0.11.0 hooks: - id: talon-fmt - id: tree-sitter-fmt diff --git a/packages/app-vscode/src/ide/vscode/VscodeEnabledHatStyleManager.ts b/packages/app-vscode/src/ide/vscode/VscodeEnabledHatStyleManager.ts index faf2054208..e4b84a6655 100644 --- a/packages/app-vscode/src/ide/vscode/VscodeEnabledHatStyleManager.ts +++ b/packages/app-vscode/src/ide/vscode/VscodeEnabledHatStyleManager.ts @@ -1,13 +1,18 @@ import { pickBy } from "lodash-es"; import vscode from "vscode"; import type { + HatColor, + HatShape, HatStyleInfo, HatStyleMap, Listener, + VscodeHatStyleName, +} from "@cursorless/lib-common"; +import { + HAT_COLORS, + HAT_NON_DEFAULT_SHAPES, + Notifier, } from "@cursorless/lib-common"; -import { Notifier } from "@cursorless/lib-common"; -import type { HatColor, HatShape, VscodeHatStyleName } from "./hatStyles.types"; -import { HAT_COLORS, HAT_NON_DEFAULT_SHAPES } from "./hatStyles.types"; export interface ExtendedHatStyleInfo extends HatStyleInfo { color: HatColor; diff --git a/packages/app-vscode/src/ide/vscode/hatStyles.types.ts b/packages/app-vscode/src/ide/vscode/hatStyles.types.ts deleted file mode 100644 index 9d19dd8f93..0000000000 --- a/packages/app-vscode/src/ide/vscode/hatStyles.types.ts +++ /dev/null @@ -1,32 +0,0 @@ -export const HAT_COLORS = [ - "default", - "blue", - "green", - "red", - "pink", - "yellow", - "userColor1", - "userColor2", - "userColor3", - "userColor4", -] as const; - -export const HAT_NON_DEFAULT_SHAPES = [ - "bolt", - "curve", - "fox", - "frame", - "play", - "wing", - "hole", - "ex", - "crosshairs", - "eye", -] as const; - -export const HAT_SHAPES = ["default", ...HAT_NON_DEFAULT_SHAPES] as const; - -export type HatColor = (typeof HAT_COLORS)[number]; -export type HatShape = (typeof HAT_SHAPES)[number]; -export type HatNonDefaultShape = (typeof HAT_NON_DEFAULT_SHAPES)[number]; -export type VscodeHatStyleName = HatColor | `${HatColor}-${HatNonDefaultShape}`; diff --git a/packages/app-vscode/src/ide/vscode/hats/VscodeHatRenderer.ts b/packages/app-vscode/src/ide/vscode/hats/VscodeHatRenderer.ts index 5116c5786b..9ab64732e0 100644 --- a/packages/app-vscode/src/ide/vscode/hats/VscodeHatRenderer.ts +++ b/packages/app-vscode/src/ide/vscode/hats/VscodeHatRenderer.ts @@ -3,15 +3,23 @@ import path from "node:path"; import { isEqual } from "lodash-es"; import vscode from "vscode"; import type { + HatShape, + IndividualHatAdjustmentMap, Listener, Messages, PathChangeListener, + VscodeHatStyleName, +} from "@cursorless/lib-common"; +import { + DEFAULT_HAT_HEIGHT_EM, + DEFAULT_VERTICAL_OFFSET_EM, + defaultShapeAdjustments, + getErrorMessage, + HAT_SHAPES, + Notifier, } from "@cursorless/lib-common"; -import { getErrorMessage, Notifier } from "@cursorless/lib-common"; import { walkFiles } from "@cursorless/lib-node-common"; import type { VscodeApi } from "@cursorless/lib-vscode-common"; -import type { HatShape, VscodeHatStyleName } from "../hatStyles.types"; -import { HAT_SHAPES } from "../hatStyles.types"; import { vscodeGetConfigurationString } from "../VscodeConfiguration"; import type { ExtendedHatStyleMap, @@ -20,12 +28,6 @@ import type { import type { FontMeasurements } from "./FontMeasurements"; import { getHatThemeColors } from "./getHatThemeColors"; import { performPr1868ShapeUpdateInit } from "./performPr1868ShapeUpdateInit"; -import type { IndividualHatAdjustmentMap } from "./shapeAdjustments"; -import { - DEFAULT_HAT_HEIGHT_EM, - DEFAULT_VERTICAL_OFFSET_EM, - defaultShapeAdjustments, -} from "./shapeAdjustments"; const CURSORLESS_HAT_SHAPES_SUFFIX = ".svg"; diff --git a/packages/app-vscode/src/ide/vscode/hats/VscodeHats.ts b/packages/app-vscode/src/ide/vscode/hats/VscodeHats.ts index 51f244f000..b79428e2b2 100644 --- a/packages/app-vscode/src/ide/vscode/hats/VscodeHats.ts +++ b/packages/app-vscode/src/ide/vscode/hats/VscodeHats.ts @@ -8,11 +8,11 @@ import type { Listener, Range, TextEditor, + VscodeHatStyleName, } from "@cursorless/lib-common"; import { Notifier } from "@cursorless/lib-common"; import type { VscodeApi } from "@cursorless/lib-vscode-common"; import { toVscodeRange } from "@cursorless/lib-vscode-common"; -import type { VscodeHatStyleName } from "../hatStyles.types"; import { VscodeEnabledHatStyleManager } from "../VscodeEnabledHatStyleManager"; import type { VscodeIDE } from "../VscodeIDE"; import type { VscodeTextEditor } from "../VscodeTextEditor"; diff --git a/packages/app-vscode/src/ide/vscode/hats/getHatThemeColors.ts b/packages/app-vscode/src/ide/vscode/hats/getHatThemeColors.ts index 24a57a2b3b..335530bafc 100644 --- a/packages/app-vscode/src/ide/vscode/hats/getHatThemeColors.ts +++ b/packages/app-vscode/src/ide/vscode/hats/getHatThemeColors.ts @@ -1,5 +1,5 @@ import vscode from "vscode"; -import type { HatColor } from "../hatStyles.types"; +import type { HatColor } from "@cursorless/lib-common"; interface OldDecorationColorSetting { dark: string; diff --git a/packages/app-vscode/src/ide/vscode/hats/getStyleName.ts b/packages/app-vscode/src/ide/vscode/hats/getStyleName.ts index 13e164d5f8..07f6670bcd 100644 --- a/packages/app-vscode/src/ide/vscode/hats/getStyleName.ts +++ b/packages/app-vscode/src/ide/vscode/hats/getStyleName.ts @@ -2,7 +2,7 @@ import type { HatColor, HatShape, VscodeHatStyleName, -} from "../hatStyles.types"; +} from "@cursorless/lib-common"; export function getStyleName( color: HatColor, diff --git a/packages/app-vscode/src/ide/vscode/hats/performPr1868ShapeUpdateInit.ts b/packages/app-vscode/src/ide/vscode/hats/performPr1868ShapeUpdateInit.ts index 570fe99a51..f624e0f1a4 100644 --- a/packages/app-vscode/src/ide/vscode/hats/performPr1868ShapeUpdateInit.ts +++ b/packages/app-vscode/src/ide/vscode/hats/performPr1868ShapeUpdateInit.ts @@ -1,9 +1,11 @@ import vscode from "vscode"; -import type { Messages } from "@cursorless/lib-common"; +import type { + IndividualHatAdjustmentMap, + Messages, +} from "@cursorless/lib-common"; import { showInfo } from "@cursorless/lib-common"; import type { VscodeApi } from "@cursorless/lib-vscode-common"; import type { ExtendedHatStyleMap } from "../VscodeEnabledHatStyleManager"; -import type { IndividualHatAdjustmentMap } from "./shapeAdjustments"; /** * We set this key in global state the first time they user gets the new shapes from #1868. We use this to diff --git a/packages/app-vscode/src/keyboard/KeyboardCommandHandler.ts b/packages/app-vscode/src/keyboard/KeyboardCommandHandler.ts index 0c920e32dc..a755007fa7 100644 --- a/packages/app-vscode/src/keyboard/KeyboardCommandHandler.ts +++ b/packages/app-vscode/src/keyboard/KeyboardCommandHandler.ts @@ -1,12 +1,13 @@ import { isString } from "lodash-es"; import vscode from "vscode"; import type { + HatColor, + HatShape, Modifier, PartialMark, SurroundingPairName, } from "@cursorless/lib-common"; import { surroundingPairsDelimiters } from "@cursorless/lib-engine"; -import type { HatColor, HatShape } from "../ide/vscode/hatStyles.types"; import type { SimpleKeyboardActionDescriptor, SpecificKeyboardActionDescriptor, diff --git a/packages/app-vscode/src/keyboard/KeyboardCommandsTargeted.ts b/packages/app-vscode/src/keyboard/KeyboardCommandsTargeted.ts index 092d7bf0e4..16bfa1d47b 100644 --- a/packages/app-vscode/src/keyboard/KeyboardCommandsTargeted.ts +++ b/packages/app-vscode/src/keyboard/KeyboardCommandsTargeted.ts @@ -1,6 +1,8 @@ import vscode from "vscode"; import type { ActionDescriptor, + HatColor, + HatShape, Modifier, PartialMark, PartialPrimitiveTargetDescriptor, @@ -10,7 +12,6 @@ import type { import { LATEST_VERSION } from "@cursorless/lib-common"; import { runCursorlessCommand } from "@cursorless/lib-vscode-common"; import { getStyleName } from "../ide/vscode/hats/getStyleName"; -import type { HatColor, HatShape } from "../ide/vscode/hatStyles.types"; import type { SimpleKeyboardActionDescriptor } from "./KeyboardActionType"; import type { KeyboardCommandsModal } from "./KeyboardCommandsModal"; import type { KeyboardHandler } from "./KeyboardHandler"; diff --git a/packages/app-vscode/src/keyboard/TokenTypes.ts b/packages/app-vscode/src/keyboard/TokenTypes.ts index 31a7c05a8c..fa23cfb7a2 100644 --- a/packages/app-vscode/src/keyboard/TokenTypes.ts +++ b/packages/app-vscode/src/keyboard/TokenTypes.ts @@ -1,8 +1,9 @@ import type { + HatColor, + HatShape, SimpleScopeTypeType, SurroundingPairName, } from "@cursorless/lib-common"; -import type { HatColor, HatShape } from "../ide/vscode/hatStyles.types"; import type { PolymorphicKeyboardActionDescriptor, SimpleKeyboardActionDescriptor, diff --git a/packages/app-vscode/src/scripts/hatAdjustments/add.ts b/packages/app-vscode/src/scripts/hatAdjustments/add.ts index ac32fcc442..5192ab729f 100644 --- a/packages/app-vscode/src/scripts/hatAdjustments/add.ts +++ b/packages/app-vscode/src/scripts/hatAdjustments/add.ts @@ -5,9 +5,8 @@ import { sum } from "lodash-es"; import type { HatAdjustments, IndividualHatAdjustmentMap, -} from "../../ide/vscode/hats/shapeAdjustments"; -import { defaultShapeAdjustments } from "../../ide/vscode/hats/shapeAdjustments"; -import { HAT_SHAPES } from "../../ide/vscode/hatStyles.types"; +} from "@cursorless/lib-common"; +import { defaultShapeAdjustments, HAT_SHAPES } from "@cursorless/lib-common"; import { postProcessValue } from "./lib"; /** diff --git a/packages/app-vscode/src/scripts/hatAdjustments/average.ts b/packages/app-vscode/src/scripts/hatAdjustments/average.ts index 0016ca807b..6eebd3de50 100644 --- a/packages/app-vscode/src/scripts/hatAdjustments/average.ts +++ b/packages/app-vscode/src/scripts/hatAdjustments/average.ts @@ -4,9 +4,8 @@ import type { HatAdjustments, IndividualHatAdjustmentMap, -} from "../../ide/vscode/hats/shapeAdjustments"; -import { defaultShapeAdjustments } from "../../ide/vscode/hats/shapeAdjustments"; -import { HAT_SHAPES } from "../../ide/vscode/hatStyles.types"; +} from "@cursorless/lib-common"; +import { defaultShapeAdjustments, HAT_SHAPES } from "@cursorless/lib-common"; import { postProcessValue } from "./lib"; /** diff --git a/packages/command-visualizer/README.md b/packages/command-visualizer/README.md new file mode 100644 index 0000000000..24ecaa5274 --- /dev/null +++ b/packages/command-visualizer/README.md @@ -0,0 +1,98 @@ +# @cursorless/command-visualizer + +Turn recorded cursorless test fixtures into **animated SVG visualizations** of +commands — before/during/after cascades with real hat allocation, flash +sequencing, selections, and a command "jumbotron". The output is a +self-contained SVG string embeddable anywhere a plain `` works (GitHub +READMEs via camo, docs pages, tutorials). + +The renderer is a pure function: fixture YAML in → self-contained SVG string +out. There is zero runtime JavaScript in the output, no DOM or browser +dependency to render, and every animation rides one CSS `--dur` timeline, so +outputs are deterministic and testable by seeking. + +```ts +import { renderCommand } from "@cursorless/command-visualizer"; + +const svg = renderCommand(fixtureYamlText, "recorded/foo/bar.yml", { + theme: "dark", +}); +``` + +## The animation model + +``` +pre (bumper) → [ step.initial → step.during → step.final ]* → post (reset) +``` + +- **The during phase is the execution beat:** the command pill activates, its + step dot gains the issued ring, and flashes initiate — all in the same + instant. Flash-less commands (pure selections) show their final state in this + beat, matching the editor's instantaneous selection. +- **Chains are contract-checked:** `finalState.i` must equal + `initialState.{i+1}`, and the boundary renders as ONE merged frame already + wearing the next step's hats — one animated change, exactly as in the editor. +- **Durations are parameters** (`initial` / `during` / `bumper` ms, plus a float + `scale`); defaults are `[2000, 1000, 500]`. +- Fixtures with no recorded `ide.flashes` (e.g. the tutorial corpus) get flashes + **derived from the edit diff**; recorded flashes are used verbatim when + present. + +Flash classes follow the action source exactly: pre-edit flashes +(`pendingDelete`, `referenced`, `pendingModification0`/`1`) precede the edit and +`justAdded` follows it. The 100ms `pendingEditDecorationTime` pulse is pinned +N-invariantly (independent of the number of frames). Pre-edit flashes render +sequenced (reference → delete) rather than in parallel — a deliberate +readability divergence from the editor. + +## Architecture + +Source is organized into four scopes with a strict one-directional dependency +rule (`render/` and `logic/` never import each other). The layering diagram, the +4-stage pipeline table, and the grep commands that verify the dependency rule +live in [`src/ARCHITECTURE.md`](./src/ARCHITECTURE.md). + +## Reuse of cursorless source + +This package deliberately imports from cursorless packages rather than cloning, +and documents the cases where a local copy is the faithful choice. + +Imported from cursorless (not cloned): + +- `DefaultMap` / `CompositeKeyMap`, `FlashStyle` — from `@cursorless/lib-common`. +- `GRAPHEME_SPLIT_REGEX`, `maxByFirstDiffering`, the `allocateHats` primitives — + from `@cursorless/lib-engine`. +- Fixture YAML is parsed with `js-yaml` (the same library `@cursorless/lib-node-common`'s + `loadFixture.ts` uses). + +Kept local, with provenance, where importing would regress or balloon scope: + +- **`data/decorations.ts`** — `FlashStyle` is a string union derived from + lib-common's `FlashStyle` enum; it composes with `HighlightStyle` into the + `OverlayStyleName` keys. The `DECORATION_HEX` values, pulse timing, and + precedence are local (cursorless does not export them). Note: this package's + `OverlayStyleName` is unrelated to lib-common's `DecorationStyle` (a + border-geometry interface) — the rename avoids that collision. +- **`data/colors.ts` / `data/shapes.ts`** — the color hexes live in + `app-vscode/package.json` as VS Code setting defaults (read at runtime, never a + TS constant) and the shape SVG `d=` path strings live in + `resources/images/hats/*.svg` (read at runtime by `VscodeHatRenderer`). There + is no importable TS module, so these are mirrored here with provenance comments + naming the exact upstream source. +- **`logic/fixture-root.ts`** — keeps its dual directory-layout probe and + `$CURSORLESS_REPO` override; lib-node-common's `getFixturesPath` hardcodes one + layout and throws unless `CURSORLESS_REPO_ROOT` is set. +- **`logic/fixture-extract.ts`** — `parseMarks` / `buildLines` read plain YAML + with no editor, whereas lib-common's `serializedMarksToTokenHats` needs a live + `TextEditor` and returns engine `TokenHat[]`. + +## Development + +```sh +# typecheck +tsc -p packages/command-visualizer/tsconfig.json --noEmit +``` + +The renderer resolves recorded fixtures from a cursorless checkout. Set +`CURSORLESS_REPO` to point at your checkout if it is not at +`$HOME/code/cursorless`. diff --git a/packages/command-visualizer/UPSTREAM_REUSE.md b/packages/command-visualizer/UPSTREAM_REUSE.md new file mode 100644 index 0000000000..28f84634ba --- /dev/null +++ b/packages/command-visualizer/UPSTREAM_REUSE.md @@ -0,0 +1,139 @@ +# Upstream reuse opportunities + +> Working/contributor doc (not shipped API). Tracks where `command-visualizer` +> could lean harder on existing cursorless code — split into what's **adoptable +> today** and what would need a **small upstream refactor + export** first. +> Goal: as a first-party package, reimplement as little as possible. + +## Hat vocabulary: consolidated (and a remaining upstream cleanup) + +The hat color/shape vocabulary (`HAT_COLORS`/`HAT_SHAPES`/`HatColor`/`HatShape`/…) +existed in FIVE places on upstream/main, but none was importable by another +package: + +- `app-vscode/.../hatStyles.types.ts` — full exported set, but app-vscode ships + only `extension.cjs`, so unreachable from other packages. +- private copies in 3 legacy command schemas (`CommandV0V1.types.ts`, + `targetDescriptorV2.types.ts`, `PartialTargetDescriptorV3.types.ts`) — frozen + historical formats, deliberately self-contained. +- a private `const HAT_COLORS` in `lib-talonjs-core/.../TalonJsTestHats.ts`. + +This PR moved the vocabulary into `lib-common/src/ide/types/hatStyles.types.ts` +(previously just a `HatStyleName = string` stub) and rewired app-vscode to import +it — creating the first importable shared home and REDUCING duplication (app-vscode +no longer defines its own). command-visualizer imports this shared definition. + +Remaining (upstream cleanup, NOT this PR): the 3 legacy schemas (frozen by design +— leave them) and the talonjs test const still hold private copies; a maintainer +could point the non-frozen ones at the shared lib-common home. + +## Status: already reused (no action) + +The package already imports from source rather than cloning: + +- Hat allocation → real `@cursorless/lib-engine` `allocateHats` (via an in-memory + `FakeIDE`/`InMemoryTextEditor`); the vendored copy is deleted. +- `HatColor`/`HAT_COLORS`, `HatShape`/`HAT_SHAPES`/`HAT_NON_DEFAULT_SHAPES`, + `FlashStyle`, `DefaultMap`, `CompositeKeyMap`, `defaultShapeAdjustments`, + `DEFAULT_HAT_HEIGHT_EM`/`DEFAULT_VERTICAL_OFFSET_EM` ← `@cursorless/lib-common`. +- `GRAPHEME_SPLIT_REGEX`, `maxByFirstDiffering`, `TokenGraphemeSplitter`, + `getTokensInRange` ← `@cursorless/lib-engine`. +- `js-yaml` for fixture YAML. + +Local-with-provenance (no importable TS source; canonical form is config/SVG): +`COLOR_MATRIX`, `DECORATION_HEX` (app-vscode `package.json` config defaults), +`SHAPE_PATHS` (`resources/images/hats/*.svg`, guarded by a drift test). + +## Adoptable today — no upstream change needed + +### Geometry: `Position` / `Range` / `GeneralizedRange` + +- **What:** `@cursorless/lib-common` exports `Position` and `Range` classes (with + `isEqual`/`isBefore`/`contains`/`with`/`fromConcise`), plus `GeneralizedRange`, + `CharacterRange`, `LineRange`, and the helpers `isLineRange`, `toLineRange`, + `toCharacterRange`, `generalizedRangeContains`/`generalizedRangeTouches`. +- **We currently hand-roll:** `model/geometry.ts` (`Pos`/`Range` plain interfaces + + `orderRange`), the ordering/`before` comparison in `deriveSelections`, and + containment math in `overlays.ts`. `model/frame-state.ts` defines its own + `CharacterRange`/`LineRange`/`GeneralizedRange`. +- **Semantics already match:** cursorless `LineRange.end` is "Last line, inclusive" + — identical to our `endLine`. The only deltas are our-side: plain interfaces → + classes (construct `new Position()`/`new Range()` at the YAML boundary), and our + `LineRange {startLine,endLine}` → cursorless's `{start,end}` (field rename, same + meaning); `CharacterRange` uses `Position` instances. +- **Blocker:** none upstream. ~8-file our-side refactor. +- **Value:** deletes real hand-rolled ordering/containment logic; inherits tested + behavior. **This is the queued geometry-adoption task.** + +## Needs an upstream refactor + export first + +Each of these is blocked by _coupling_ (live `TextEditor` / disk I/O / no pure +unit), not merely a missing export — so it needs a small cursorless-side change +before we can consume it. Listed by value. + +| # | If cursorless… | We'd delete | Value | Feasibility | +| --- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- | ------------------------------------ | +| 1 | extracted a **pure flash-derivation** — `diff(beforeDoc, afterDoc) → {pendingDelete, justAdded} ranges` — from the action runtime | `logic/derive-flashes.ts` | **High** (real duplicated logic) | **Hard** — no pure unit exists today | +| 2 | offered an **editor-free `serializedMarksToTokenHats`** taking plain `{offset,text}` instead of a live `TextEditor` | `parseMarks` + mark attachment in `logic/fixture-extract.ts` | Medium | Medium | +| 3 | exported a **grapheme-level tokenizer** (or made `getTokensInRange` output headless-consumable as a render model) | most of `logic/tokenize.ts`, part of `model/columns.ts` | Medium | Medium (word-vs-grapheme gap) | +| 4 | **parameterized fixture-path** resolution — `getFixturesPath(root)` instead of env-var-throw + one hardcoded layout | `logic/fixture-root.ts` | Low | Easy | + +### 1. Pure flash-derivation (highest value) + +Today, cursorless flashes are a **runtime side-effect**: actions execute against a +live IDE and call `ide().flashRanges(...)` (see `lib-engine/src/actions/*`, +`core/updateSelections/`, `RangeUpdater`). There is no pure +"two document snapshots → flash ranges" function anywhere. Recorded fixtures +carry **zero** `ide.flashes`, so `derive-flashes.ts` reconstructs them from a +char-level prefix/suffix diff. If cursorless factored the edit→flash mapping into +a pure helper, we'd import it and delete our diff — and it would benefit +cursorless's own test/tooling surface, not just us. **Best upstream proposal +candidate.** + +### 2. Editor-free marks → hats + +`lib-common/src/util/serializedMarksToTokenHats.ts` hard-requires a live +`TextEditor` (`editor.document.offsetAt(range)`, `.getText(range)`) and returns +engine `TokenHat[]`. Our `parseMarks` reads plain fixture YAML (`{color}.{grapheme}`) +with no editor and yields the render-model `MarkInfo`. A pure core that takes +offsets/text (splitting the editor I/O from the mapping) would let us drop ours. + +### 3. Grapheme tokenizer / headless token model + +The engine tokenizer produces **word/token** units, not one-token-per-grapheme, +and isn't barrel-exported; `getTokensInRange` needs a live editor. `columns.ts` +also needs East-Asian display width — for which **no util exists anywhere** in the +monorepo. A grapheme-level tokenizer export (or a headless render-token producer) +would shrink `tokenize.ts`/`columns.ts`; the display-width piece would still be +ours unless cursorless grew one (it never needed display geometry). + +### 4. Parameterized fixture path + +`lib-node-common` `getCursorlessRepoRoot()` throws unless `CURSORLESS_REPO_ROOT` +is set, and `getFixturesPath` hardcodes the single `resources/fixtures` layout. +Ours does a dual-layout probe with a sensible default. A root-param overload would +let us drop `fixture-root.ts`. + +## Not reusable (genuinely new — no upstream candidate) + +- `model/timeline.ts` (animation timing), `logic/chain.ts` (multi-step chaining), + `render/*` (HTML/CSS/SVG hat renderer — nothing like it exists upstream; + `app-web-docs/Code.tsx` is Shiki syntax highlighting, unrelated). +- `model/overlays.ts` cross-decoration last-wins-per-cell precedence — lib-common's + `decorationUtil` computes single-range **border geometry**, a different concern. +- `model/frame-state.ts` container types (checked 2026-07-08 against cursorless): + its FIELD types already come from lib-common (`Position`/`Range`/`GeneralizedRange`), + but the containers have no home. `Decoration {style,range,role}` is the only real + shape-overlap — with `FlashDescriptor {style, editor, range}` — but that requires + a live `TextEditor`, is flash-only (no `highlight0/1`), and lacks `role`; not + adoptable without an editor-free + highlight-inclusive upstream refactor, and even + then only partial. `Frame`/`CascadeState`/`CascadeMeta` are render/animation models + distinct from `TestCaseSnapshot` (raw text + marks). `FrameRole`/`OverlayRole` are + render enums. Do not re-chase. + +## How to pursue + +The geometry adoption is our-side and unblocked — do it directly. Items 1–4 are +upstream-contribution opportunities: raise as cursorless issues/PRs (extract the +pure core, add the export), then consume. Item 1 (pure flash-derivation) is the +highest-leverage and the most defensible as a general cursorless improvement. diff --git a/packages/command-visualizer/package.json b/packages/command-visualizer/package.json new file mode 100644 index 0000000000..dd42eb09b8 --- /dev/null +++ b/packages/command-visualizer/package.json @@ -0,0 +1,35 @@ +{ + "name": "@cursorless/command-visualizer", + "version": "0.0.1", + "description": "Visualizes cursorless commands as animated SVGs: the spoken command entering, flashes firing, the edit landing, hats reallocating — rendered from recorded test fixtures, embeddable via plain .", + "license": "MIT", + "type": "module", + "main": "./out/index.js", + "types": "./out/index.d.ts", + "exports": { + ".": "./src/index.ts" + }, + "keywords": [ + "cursorless", + "visualization", + "svg", + "fixtures" + ], + "scripts": { + "typecheck": "tsc", + "clean": "rm -rf ./out ./dist ./tsconfig.tsbuildinfo", + "compile:tsc": "tsc", + "compile:esbuild": "esbuild ./src/index.ts --sourcemap --format=esm --bundle --packages=external --outfile=./out/index.js", + "compile": "pnpm compile:tsc && pnpm compile:esbuild" + }, + "dependencies": { + "@cursorless/lib-common": "workspace:*", + "@cursorless/lib-engine": "workspace:*", + "js-yaml": "^5.2.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/mocha": "^10.0.10", + "@types/node": "^24.13.2" + } +} diff --git a/packages/command-visualizer/src/ARCHITECTURE.md b/packages/command-visualizer/src/ARCHITECTURE.md new file mode 100644 index 0000000000..25904e071c --- /dev/null +++ b/packages/command-visualizer/src/ARCHITECTURE.md @@ -0,0 +1,71 @@ +# command-visualizer — source architecture + +A clear separation between **what is visually seen** and **what is under the hood**. + +## The four scopes + +``` +data/ static constants (colors, decorations, shapes) +model/ the shared contract: types + PURE deterministic transforms both sides use +logic/ "under the hood": produces a CascadeState from a fixture (emits ZERO html) +render/ "what is visually seen": CascadeState -> HTML/CSS/SVG +``` + +- `data/` — colors.ts, decorations.ts, shapes.ts +- `model/` — types.ts (package-defined render-model types: CascadeState/Frame/Decoration/FrameRole/OverlayRole; geometry `Position`/`Range`/`GeneralizedRange` come from `@cursorless/lib-common`), columns.ts (Line/Token/Column geometry), overlays.ts (decoration → column overlay resolution), timeline.ts (frame timing) +- `logic/` — pipeline.ts, pipeline-types.ts, build-render-object.ts, fixture-extract.ts, fixture-yaml.ts, fixture-root.ts, tokenize.ts, hat-allocator.ts, chain.ts +- `render/` — serialize.ts, serialize-cascade.ts, svg-wrap.ts, jumbotron.ts, css.ts, css-cascade.ts, symbols.ts, html.ts +- `render-command.ts` — the top-level 4-stage orchestrator (root, spans logic + render). +- `index.ts` — the public surface; wires logic + render + model exports together. + +## Pipeline — 4 stages + +The whole tool is one legible top-to-bottom progression. Read `renderCommand` +in `render-command.ts` to see all four stages at a glance; each delegates to a +named function: + +| # | Stage | Function | File | +| --- | ---------------------- | ------------------------------------- | --------------------------------------------------- | +| 1 | get what to render | `parseFixture` | `logic/pipeline.ts` | +| 2 | tokenize each step | `tokenizeStates` | `logic/pipeline.ts` | +| 3 | generate render object | `buildRenderObject` | `logic/build-render-object.ts` | +| 4 | render from object | `serializeCascade` + `wrapCascadeSvg` | `render/serialize-cascade.ts`, `render/svg-wrap.ts` | + +Stages 1–3 produce the `CascadeState` render object; stage 4 serializes it to +an animated SVG. `fixtureToCascade` (exported, unchanged behavior) is a thin +composition of stages 1–3; `renderCommand` composes all four. Shared stage +types live in `logic/pipeline-types.ts` so the stage files can reference the +contract without a circular import. + +**Why the orchestrator lives at the root, not in `logic/`:** stage 4 is in +`render/`, and `logic/` must never import `render/` (the rule below). The +4-stage orchestrator therefore lives at the package root (`render-command.ts`, +alongside `index.ts`) — the ONE allowed composition point that may import from +both `logic/` and `render/`. Putting it inside `logic/` would create a +forbidden `logic/ → render/` edge. + +## The one-directional dependency rule + +**`render/` must NEVER import from `logic/`, and `logic/` must NEVER import from `render/`.** + +Both scopes may import `model/` and `data/`. `model/` imports only `model/` and `data/`. Nothing imports `index.ts` internally. + +``` + index.ts + / \ + logic/ render/ (siblings — no edge between them) + \ / + model/ + | + data/ +``` + +`columns`, `overlays`, and `timeline` live in `model/` (not `logic/` or `render/`) precisely because they are pure transforms consumed by BOTH sides — placing them in `model/` is what lets the rule hold automatically, so render can resolve every shared dependency without ever reaching into logic. + +## Verifying the rule + +```sh +# both MUST print nothing: +grep -rn 'from "\.\./logic/\|from "\./logic/' packages/command-visualizer/src/render +grep -rn 'from "\.\./render/\|from "\./render/' packages/command-visualizer/src/logic +``` diff --git a/packages/command-visualizer/src/data/colors.ts b/packages/command-visualizer/src/data/colors.ts new file mode 100644 index 0000000000..07346b5244 --- /dev/null +++ b/packages/command-visualizer/src/data/colors.ts @@ -0,0 +1,49 @@ +// COLOR_MATRIX / EDITOR_CHROME are the only command-visualizer-local color data. +// The theme hexes have no importable TS home: they're declared in +// app-vscode/package.json as VS Code setting defaults (\`cursorless.colors.dark\`/ +// \`.light\`) and read at runtime from VS Code config — there's no compile-time +// constant to import, so a headless renderer mirrors the defaults here (verified +// against app-vscode/package.json). +// +// The hat color VOCABULARY (\`HatColor\`, \`HAT_COLORS\`) is NOT re-exported here — +// consumers import it directly from @cursorless/lib-common. + +import type { HatColor } from "@cursorless/lib-common"; + +export type Theme = "dark" | "light"; + +export const COLOR_MATRIX: Record> = { + dark: { + default: "#B9B6CD", + blue: "#089ad3", + green: "#36B33F", + red: "#E02D28", + pink: "#E06CAA", + yellow: "#E5C02C", + userColor1: "#6a00ff", + userColor2: "#ffd8b1", + userColor3: "#6b8e23", + userColor4: "#e0e0e0", + }, + light: { + default: "#757180", + blue: "#089ad3", + green: "#36B33F", + red: "#E02D28", + pink: "#e0679f", + yellow: "#edb62b", + userColor1: "#6a00ff", + userColor2: "#ffd8b1", + userColor3: "#6b8e23", + userColor4: "#e0e0e0", + }, +}; + +// Editor chrome colors (VS Code dark+ / light+ defaults). +export const EDITOR_CHROME: Record< + Theme, + { bg: string; fg: string; sel: string; caret: string } +> = { + dark: { bg: "#1e1e1e", fg: "#d4d4d4", sel: "#264f78", caret: "#aeafad" }, + light: { bg: "#ffffff", fg: "#1f1f1f", sel: "#add6ff", caret: "#000000" }, +}; diff --git a/packages/command-visualizer/src/data/decorations.ts b/packages/command-visualizer/src/data/decorations.ts new file mode 100644 index 0000000000..5cc77cbb33 --- /dev/null +++ b/packages/command-visualizer/src/data/decorations.ts @@ -0,0 +1,76 @@ +// Decoration style hexes — VERBATIM from cursorless flash/highlight palette. +// All background-only; alpha baked into the 8-digit hex; theme-INVARIANT. + +import { FlashStyle as CursorlessFlashStyle } from "@cursorless/lib-common"; + +// FlashStyle is DERIVED from @cursorless/lib-common's `FlashStyle` enum +// (ide/types/FlashDescriptor.ts) — not cloned. The template-literal type turns +// the enum's string VALUES into the union +// "pendingDelete" | "referenced" | "pendingModification0" | +// "pendingModification1" | "justAdded", so every existing string-keyed usage +// (DECORATION_HEX keys, "pendingDelete" as OverlayStyleName, styles.has(...)) +// keeps working with zero literal churn, while the set of valid names now +// tracks lib-common automatically. The HEX values, FLASH_PULSE_MS, +// MS_PER_STATE, and precedence remain ours (not exported by cursorless). +export type FlashStyle = `${CursorlessFlashStyle}`; + +export type HighlightStyle = "highlight0" | "highlight1"; + +export type OverlayStyleName = FlashStyle | HighlightStyle; + +// All 7 decoration styles' background hexes (the 2 scope-pair styles use the +// same band hex family; per-edge borders are not modeled here). +export const DECORATION_HEX: Record = { + pendingDelete: "#ff00008a", + justAdded: "#09ff005b", + referenced: "#00a2ff4d", + pendingModification0: "#8c00ff86", + pendingModification1: "#ff009d7e", + highlight0: "#d449ff42", + highlight1: "#60daff7a", +}; + +// Derived from lib-common's FlashStyle enum (the 5 flash styles) rather than +// re-listing the names — the set now tracks source automatically. Order is not +// significant: consumers use it for membership tests and per-style CSS rule +// emission with distinct selectors (css-cascade.ts). +export const FLASH_STYLES: FlashStyle[] = Object.values(CursorlessFlashStyle); + +export const HIGHLIGHT_STYLES: HighlightStyle[] = ["highlight0", "highlight1"]; + +// Flash PULSE duration — pinned VERBATIM to cursorless's +// `cursorless.pendingEditDecorationTime` default (100ms): +// packages/app-vscode/package.json:375 ("default": 100) +// packages/app-vscode/src/ide/vscode/VscodeFlashHandler.ts:26 +// flashRanges(...) → await sleep(getPendingEditDecorationTime()) → clear +// A flash is a FIXED 100ms pulse, decoupled from the readability state-hold +// cadence. Both the delete (pendingDelete) and insert +// (justAdded) beats are pinned to this. verify:flash-timing is the oracle. +export const FLASH_PULSE_MS = 100; + +// Real-time duration of one readability state-hold slot, in ms. The cascade +// timeline is `--dur = N · MS_PER_STATE` (serialize-cascade.ts), so each of the +// N frame slots lasts exactly MS_PER_STATE ms regardless of N. The flash pulse +// (FLASH_PULSE_MS) is pinned in absolute ms and is INDEPENDENT of this cadence — +// changing MS_PER_STATE rescales the state-hold but never the 100ms flash. +export const MS_PER_STATE = 1000; + +export const ALL_DECORATION_STYLES: OverlayStyleName[] = [ + ...FLASH_STYLES, + ...HIGHLIGHT_STYLES, +]; + +// Single-winner precedence: selection < highlight < flash, last wins; exactly +// ONE background per cell. Higher number wins. +export function overlayPrecedence( + style: OverlayStyleName | "selection", +): number { + if (style === "selection") { + return 0; + } + if (HIGHLIGHT_STYLES.includes(style as HighlightStyle)) { + return 1; + } + // flash + return 2; +} diff --git a/packages/command-visualizer/src/data/shapes.test.ts b/packages/command-visualizer/src/data/shapes.test.ts new file mode 100644 index 0000000000..4e93c2fde1 --- /dev/null +++ b/packages/command-visualizer/src/data/shapes.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { HAT_SHAPES } from "@cursorless/lib-common"; +import { SHAPE_PATHS } from "./shapes"; + +// Single-source guard: SHAPE_PATHS carries byte-for-byte copies of the hat SVG +// `d=` path data, because a headless renderer can't read the .svg files at +// runtime (bundled/serverless path never touches disk). This test makes +// `resources/images/hats/*.svg` the enforced source of truth — if a shape's +// path (or crosshairs' fill-rule) ever drifts from the checked-in copy, this +// fails and points at the exact shape to re-sync. Runtime stays pure; the fs +// read lives here, at test time, only. + +const HATS_DIR = new URL("../../../../resources/images/hats/", import.meta.url); + +function svgOf(shape: string): string { + return readFileSync(new URL(`${shape}.svg`, HATS_DIR), "utf8"); +} + +suite("command-visualizer/data/shapes", () => { + test("SHAPE_PATHS covers exactly the hat shapes", () => { + assert.deepEqual( + Object.keys(SHAPE_PATHS).toSorted(), + [...HAT_SHAPES].toSorted(), + ); + }); + + test("every SHAPE_PATHS entry matches resources/images/hats/.svg", () => { + for (const shape of HAT_SHAPES) { + const svg = svgOf(shape); + + const d = /]*\sd="([^"]+)"/u.exec(svg)?.[1]; + assert.equal( + SHAPE_PATHS[shape].d, + d, + `d= drift for shape "${shape}" — re-sync from resources/images/hats/${shape}.svg`, + ); + + const fillRule = /fill-rule="([^"]+)"/u.exec(svg)?.[1]; + assert.equal( + SHAPE_PATHS[shape].fillRule, + fillRule, + `fill-rule drift for shape "${shape}"`, + ); + } + }); +}); diff --git a/packages/command-visualizer/src/data/shapes.ts b/packages/command-visualizer/src/data/shapes.ts new file mode 100644 index 0000000000..5dd86c8cb4 --- /dev/null +++ b/packages/command-visualizer/src/data/shapes.ts @@ -0,0 +1,58 @@ +// SHAPE_PATHS is the only command-visualizer-local shape data. The SVG \`d=\` path +// strings have no importable TS home: their canonical source is the 11 .svg files +// under resources/images/hats/, which app-vscode's VscodeHatRenderer reads at +// runtime — there's no compile-time constant, so a headless renderer carries the +// byte-for-byte paths here (verified against the source SVGs). +// +// The shape VOCABULARY (\`HatShape\`, \`HAT_SHAPES\`, ...) and the shape-adjustment +// constants (\`defaultShapeAdjustments\`, \`DEFAULT_HAT_HEIGHT_EM\`, +// \`DEFAULT_VERTICAL_OFFSET_EM\`) are NOT re-exported here — consumers import them +// directly from @cursorless/lib-common. + +import type { HatShape } from "@cursorless/lib-common"; + +export interface ShapePath { + d: string; + /** Only set for crosshairs. */ + fillRule?: "evenodd"; +} + +// d= strings are byte-for-byte from resources/images/hats/{shape}.svg. +// Only `crosshairs` carries fill-rule="evenodd"; every other shape uses the +// SVG default (nonzero). `ex` is a single subpath (no hole). +export const SHAPE_PATHS: Record = { + default: { + d: "M6 9C9.31371 9 12 6.98528 12 4.5C12 2.01472 9.31371 0 6 0C2.68629 0 0 2.01472 0 4.5C0 6.98528 2.68629 9 6 9Z", + }, + bolt: { + d: "M12 4V0C12 0 9 5 8 5C7 5 3 0 3 0L0 5V9C0 9 3 5 4 5C5 5 9 9 9 9L12 4Z", + }, + curve: { + d: "M6.00016 3.5C10 3.5 12 7.07378 12 9C12 4 10.5 0 6.00016 0C1.50032 0 0 4 0 9C0 7.07378 2.00032 3.5 6.00016 3.5Z", + }, + fox: { + d: "M6.00001 9L0 0C0 0 3.71818 2.5 6 2.5C8.28182 2.5 12 0 12 0L6.00001 9Z", + }, + frame: { + d: "M0 0.000115976V8.99988H12V0L0 0.000115976ZM9.5 6.5H6H2.5V4.5V2.5H6H9.5V4.5V6.5Z", + }, + play: { + d: "M12 4.49999L0 9C0 9 3 6.2746 3 4.49999C3 2.72537 0 0 0 0L12 4.49999Z", + }, + wing: { + d: "M6 0C6 0 7 3 8.5 4.5C10 6 12 7 12 7V9C12 9 8.5 7 6 7C3.5 7 0 9 0 9V7C0 7 2 6 3.5 4.5C5 3 6 0 6 0Z", + }, + hole: { + d: "M1.5 4.5L0 7H2.5L3.5 9L6 7.5L8.5 9L9.5 7H12L10.5 4.5L12 2H9.5L8.5 0L6 1.5L3.5 0L2.5 2H0Z M6 5.5L4 6.5L3 4.5L4 2.5L6 3.5L8 2.5L9 4.5L8 6.5L6 5.5Z", + }, + ex: { + d: "M9.99997 9C9.99997 9 7.5 6.5 6 6.5C4.5 6.5 2 9 2 9C2 9 0.999999 9 0 9C0 9 2.5 6 2.5 4.5C2.5 3 6.5473e-05 0 6.5473e-05 0C6.5473e-05 0 1 0 2 0C2 0 4.5 2.5 6 2.5C7.5 2.5 9.99997 0 9.99997 0C11 0 12 0 12 0C12 0 9.5 3 9.5 4.5C9.5 6 12 9 12 9C12 9 11 9 9.99997 9Z", + }, + crosshairs: { + d: "M5.25 0C5.25 0 4.5 1.5 3.5 2.5C2.49483 3.50517 0 3.75 0 3.75V5.25C0 5.25 2.49483 5.49483 3.5 6.5C4.5 7.5 5.25 9 5.25 9H6.75C6.75 9 7.5 7.5 8.5 6.5C9.50517 5.49483 12 5.25 12 5.25V3.75C12 3.75 9.50517 3.50517 8.5 2.5C7.5 1.5 6.75 0 6.75 0H5.25ZM5.75 6.5H6.25C6.25 6.5 6.58435 5.25599 7 5C7.41565 4.74401 8.75 4.75 8.75 4.75V4.25C8.75 4.25 7.41565 4.25599 7 4C6.58435 3.74401 6.25 2.5 6.25 2.5H5.75C5.75 2.5 5.41565 3.74401 5 4C4.58435 4.25599 3.25 4.25 3.25 4.25V4.75C3.25 4.75 4.58435 4.74401 5 5C5.41565 5.25599 5.75 6.5 5.75 6.5Z", + fillRule: "evenodd", + }, + eye: { + d: "M12 4L6.5 0H5.5L0 4V5L5.5 9H6.5L12 5V4ZM6 7.5C6 7.5 4.5 6.5 4.5 4.5C4.5 2.5 6.01103 1.5 6 1.5C6 1.5 7.5 2.5 7.5 4.5C7.5 6.5 6 7.5 6 7.5Z", + }, +}; diff --git a/packages/command-visualizer/src/index.ts b/packages/command-visualizer/src/index.ts new file mode 100644 index 0000000000..4c79169e14 --- /dev/null +++ b/packages/command-visualizer/src/index.ts @@ -0,0 +1,33 @@ +// @cursorless/command-visualizer — public surface. +// +// fixture YAML in -> animated SVG out. See ../README.md for an overview. + +export { + fixtureToCascade, + parseFixture, + tokenizeStates, + buildRenderObject, + type PipelineOptions, + type ParsedFixture, + type TokenizedStates, +} from "./logic/pipeline"; +export { renderCommand, type RenderCommandOptions } from "./render-command"; +export { + chainCascades, + withBumpers, + ChainContinuityError, +} from "./logic/chain"; +export { + serializeCascade, + serializeCascadeDocument, +} from "./render/serialize-cascade"; +export { wrapCascadeSvg } from "./render/svg-wrap"; +export { serializeJumbotron, jumbotronCss } from "./render/jumbotron"; +export { + timelineOf, + frameDurMs, + INITIAL_MS, + DURING_MS, + BUMPER_MS, +} from "./model/timeline"; +export type { CascadeState, Frame } from "./model/types"; diff --git a/packages/command-visualizer/src/logic/build-render-object.ts b/packages/command-visualizer/src/logic/build-render-object.ts new file mode 100644 index 0000000000..fbef591412 --- /dev/null +++ b/packages/command-visualizer/src/logic/build-render-object.ts @@ -0,0 +1,118 @@ +// Stage 3: generate render object. Extracted from pipeline.ts so each module +// stays under the 250-line ceiling. Assembles flashes, the DURING frame, and +// overlays from the tokenized before/after Frames into the CascadeState. +// Behavior identical to the inline stage that lived in fixtureToCascade. +// logic/ → logic/ import only. + +import type { GeneralizedRange } from "@cursorless/lib-common"; +import type { OverlayStyleName } from "../data/decorations"; +import type { CascadeState, Frame } from "../model/types"; +import { deriveFlashes } from "./derive-flashes"; +import { deriveOverlays } from "./derive-overlays"; +import { asArr, asObj, toGeneralizedRange } from "./fixture-extract"; +import type { + ParsedFixture, + PipelineOptions, + TokenizedStates, +} from "./pipeline-types"; + +// Route a flash style to its native frame. +function flashRidesAfter(style: string): boolean { + return style === "justAdded"; +} + +/** Assemble flashes, the DURING frame, and overlays into the CascadeState. */ +export function buildRenderObject( + parsed: ParsedFixture, + tokenized: TokenizedStates, + _opts: PipelineOptions = {}, +): CascadeState { + const { theme, tabSize, meta, initial, final, ide } = parsed; + const { frames, beforeFrame, afterFrame } = tokenized; + + const duringFlashes: { style: OverlayStyleName; range: GeneralizedRange }[] = + []; + + // Step 6b (derived referenceFlashes) — see derive-flashes.ts. When a fixture + // records no ide.flashes but the doc changed, synthesize the pre-edit + // pendingDelete (rides DURING) + post-edit justAdded (rides AFTER) from the + // char-level prefix/suffix diff. Behavior identical to the inline version. + const recordedFlashes = asArr(ide?.flashes); + const initDoc = (initial?.documentContents as string) ?? ""; + const finDoc = + typeof final?.documentContents === "string" ? final.documentContents : null; + const derived = deriveFlashes({ + recordedFlashCount: recordedFlashes.length, + initDoc, + finDoc, + hasAfterFrame: afterFrame != null, + }); + duringFlashes.push(...derived.duringFlashes); + if (afterFrame) { + afterFrame.decorations.push(...derived.afterDecorations); + } + + // Step 6: flashes. justAdded rides the AFTER frame (post-edit); every + // other flash is PRE-EDIT and rides the dedicated DURING frame (built + // below) — reference-class flashes sequence before deletion flashes there. + for (const f of asArr(ide?.flashes)) { + const fo = asObj(f); + if (!fo) { + continue; + } + const style = String(fo.style) as OverlayStyleName; + const range = toGeneralizedRange(asObj(fo.range) ?? {}); + if (!range) { + continue; + } + if (flashRidesAfter(style) && afterFrame) { + afterFrame.decorations.push({ style, range, role: "flash" }); + } else { + duringFlashes.push({ style, range }); + } + } + + // Build the DURING frame (the execution beat — the instant the command + // pill goes active). ALWAYS present when the step has a final state, so + // every command occupies the same time scope. Content depends on flashes: + // - WITH pre-edit flashes: the initial doc, flashes firing (reference + // half then delete half) — the edit lands at the phase end. + // - WITHOUT flashes (pure selection commands like "take cap"): the edit + // is instantaneous in a real editor, so the during frame shows the + // FINAL state — the selection highlight lands in the SAME frame as the + // pill activation. + if (afterFrame) { + const instant = duringFlashes.length === 0; + const src = instant ? afterFrame : beforeFrame; + const duringFrame: Frame = { + role: "during", + lines: src.lines, + cursors: src.cursors, + selections: src.selections, + decorations: instant + ? [] + : duringFlashes.map(({ style, range }) => ({ + style, + range, + role: "flash" as const, + })), + clipboard: src.clipboard, + }; + frames.splice(1, 0, duringFrame); + } + + // Step 7: highlights → BEFORE decorations, thatMark/sourceMark → AFTER + // decorations. See derive-overlays.ts; behavior identical to the inline + // version (order preserved: highlights, then that, then source). + const overlays = deriveOverlays({ + ide, + final, + hasAfterFrame: afterFrame != null, + }); + beforeFrame.decorations.push(...overlays.beforeDecorations); + if (afterFrame) { + afterFrame.decorations.push(...overlays.afterDecorations); + } + + return { theme, tabSize, meta, frames }; +} diff --git a/packages/command-visualizer/src/logic/chain.test.ts b/packages/command-visualizer/src/logic/chain.test.ts new file mode 100644 index 0000000000..1c61f69143 --- /dev/null +++ b/packages/command-visualizer/src/logic/chain.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import type { CascadeState, Frame } from "../model/types"; +import { chainCascades, ChainContinuityError } from "./chain"; + +function beforeFrame(): Frame { + return { + role: "before", + lines: [], + cursors: [], + selections: [], + decorations: [], + }; +} + +function singleStepState(meta: CascadeState["meta"]): CascadeState { + return { theme: "dark", tabSize: 2, meta, frames: [beforeFrame()] }; +} + +suite("command-visualizer/chain", () => { + suite("chainCascades", () => { + test("throws ChainContinuityError with step index 0 on empty input", () => { + let caught: unknown; + try { + chainCascades([], "recorded/x.yml"); + } catch (error) { + caught = error; + } + assert.ok(caught instanceof ChainContinuityError); + assert.equal((caught as ChainContinuityError).stepIndex, 0); + }); + + test("single-step: applies fixtureLabel while preserving other meta", () => { + const state = singleStepState({ + spokenForm: "chuck", + action: "remove", + fixture: "per-step.yml", + }); + const result = chainCascades([state], "recorded/chuck.yml"); + assert.equal(result.meta?.fixture, "recorded/chuck.yml"); + assert.equal(result.meta?.spokenForm, "chuck"); + assert.equal(result.meta?.action, "remove"); + }); + + test("single-step: sets fixtureLabel even when the state has no meta", () => { + const state = singleStepState(undefined); + const result = chainCascades([state], "recorded/only.yml"); + assert.equal(result.meta?.fixture, "recorded/only.yml"); + }); + }); +}); diff --git a/packages/command-visualizer/src/logic/chain.ts b/packages/command-visualizer/src/logic/chain.ts new file mode 100644 index 0000000000..e717bf9919 --- /dev/null +++ b/packages/command-visualizer/src/logic/chain.ts @@ -0,0 +1,142 @@ +// Multi-step chain semantics. Model: +// +// every entry: initialState / referenceFlashes[] / finalState +// chained: finalState.i and initialState.{i+1} MUST agree, and the +// hats from initialState.{i+1} flow BACKWARD onto that +// boundary — in cursorless the transition is ONE animated +// change, not two. finalState.i is never rendered with its +// own hats; the merged frame IS initialState.{i+1}. +// +// Frame list for an n-step chain: [before_0, before_1, ..., before_{n-1}, after_{n-1}] +// where each merged frame before_{i+1}: +// - renders step {i+1}'s initialState (hats = its marks + real allocation), +// - inherits step i's AFTER-riding decorations (justAdded flashes, thatMark +// references) — they light at the merged frame's slot START, +// - keeps its own BEFORE-riding decorations (pendingDelete) — they light at +// the merged frame's slot END, +// - carries step i's produced clipboard when step {i+1} has none. + +import type { CascadeState, Frame } from "../model/types"; + +/** + * Pre-gif / post-gif bumpers: a 500ms PRE frame (initial state, before step 0 + * begins) and a 500ms RESET frame (re-shows the initial state so the infinite + * loop wraps onto identical pixels). Applied to every animated cascade. + */ +export function withBumpers(state: CascadeState): CascadeState { + if (state.frames.length < 2) { + return state; + } + const first = state.frames[0]; + const clone = (flags: Partial): Frame => ({ + role: "after", + lines: first.lines, + cursors: first.cursors, + selections: first.selections, + decorations: [], + clipboard: first.clipboard, + ...flags, + }); + return { + ...state, + frames: [ + clone({ role: "before", pre: true }), + ...state.frames, + clone({ reset: true }), + ], + }; +} + +export class ChainContinuityError extends Error { + constructor( + public stepIndex: number, + message: string, + ) { + super(message); + this.name = "ChainContinuityError"; + } +} + +/** Reconstruct a frame's document text from its render tokens (GATE 0 exact). */ +export function frameDocText(frame: Frame): string { + return frame.lines + .map((line) => line.tokens.map((t) => t.text).join("")) + .join("\n"); +} + +/** + * Merge per-step cascades ([before, after] each) into one chain cascade. + * Throws ChainContinuityError when finalState.i !== initialState.{i+1}. + */ +export function chainCascades( + states: CascadeState[], + fixtureLabel: string, +): CascadeState { + if (states.length === 0) { + throw new ChainContinuityError( + 0, + "chainCascades requires at least one state", + ); + } + if (states.length === 1) { + return { ...states[0], meta: { ...states[0].meta, fixture: fixtureLabel } }; + } + + const frames: Frame[] = []; + for (let i = 0; i < states.length; i++) { + const step = states[i]; + const before = step.frames.find((f) => f.role === "before"); + const after = step.frames.find((f) => f.role === "after"); + if (!before) { + throw new ChainContinuityError(i, `step ${i} has no before frame`); + } + + if (i > 0) { + const prevAfter = states[i - 1].frames.find((f) => f.role === "after"); + if (!prevAfter) { + throw new ChainContinuityError( + i - 1, + `step ${i - 1} has no finalState to chain from`, + ); + } + const prevDoc = frameDocText(prevAfter); + const thisDoc = frameDocText(before); + if (prevDoc !== thisDoc) { + throw new ChainContinuityError( + i, + `chain discontinuity between steps ${i - 1} and ${i}: ` + + `finalState.${i - 1} and initialState.${i} must agree. ` + + `finalState.${i - 1}=${JSON.stringify(prevDoc).slice(0, 80)} ` + + `initialState.${i}=${JSON.stringify(thisDoc).slice(0, 80)}`, + ); + } + // Backward hat flow: the merged frame IS this step's before (its + // hats). Step i-1's AFTER-riding decorations + clipboard transfer + // onto it; prevAfter itself is never rendered. + before.decorations = [...prevAfter.decorations, ...before.decorations]; + if (before.clipboard == null && prevAfter.clipboard != null) { + before.clipboard = prevAfter.clipboard; + } + } + frames.push(before); + + // The step's DURING phase (pre-edit flash window) rides between its + // initial and the next merged frame. + const during = step.frames.find((f) => f.role === "during"); + if (during) { + frames.push(during); + } + + if (i === states.length - 1) { + if (after) { + frames.push(after); + } + } + } + + return { + ...states[0], + meta: { fixture: fixtureLabel }, + frames, + }; +} diff --git a/packages/command-visualizer/src/logic/derive-flashes.ts b/packages/command-visualizer/src/logic/derive-flashes.ts new file mode 100644 index 0000000000..719d3eb9ac --- /dev/null +++ b/packages/command-visualizer/src/logic/derive-flashes.ts @@ -0,0 +1,96 @@ +// Derived referenceFlashes. Extracted verbatim from +// pipeline.ts so fixtureToCascade stays under the 250-line limit. Pure: takes +// the two document snapshots, returns the flashes to synthesize. No I/O, no +// mutation of caller state. logic/ → logic/ import only. + +import type { GeneralizedRange } from "@cursorless/lib-common"; +import { Position } from "@cursorless/lib-common"; +import type { OverlayStyleName } from "../data/decorations"; +import type { Decoration } from "../model/types"; + +export interface DerivedFlashes { + /** pendingDelete flashes to append to the DURING flash list (pre-edit). */ + duringFlashes: { style: OverlayStyleName; range: GeneralizedRange }[]; + /** justAdded flash decorations to push onto the AFTER frame (post-edit). */ + afterDecorations: Decoration[]; +} + +function toPos(doc: string, off: number): Position { + const upto = doc.slice(0, off); + const line = (upto.match(/\n/gu) ?? []).length; + const character = off - (upto.lastIndexOf("\n") + 1); + return new Position(line, character); +} + +/** + * Derived referenceFlashes: tutorial-corpus recordings carry NO + * ide.flashes (all 10 tutorial-1-basics fixtures have zero) even for document + * edits. Cursorless flashes are deterministic from + * the edit itself, so when a fixture records none and the doc changed, + * derive them: char-level common prefix/suffix -> removed span flashes + * pendingDelete on BEFORE, inserted span flashes justAdded on AFTER. + * + * Returns empty lists (no synthesis) when the guard conditions don't hold: + * the fixture recorded flashes, there is no final doc, the doc is unchanged, + * or there is no after frame to attach the justAdded flash to. + */ +export function deriveFlashes(args: { + recordedFlashCount: number; + initDoc: string; + finDoc: string | null; + hasAfterFrame: boolean; +}): DerivedFlashes { + const { recordedFlashCount, initDoc, finDoc, hasAfterFrame } = args; + const duringFlashes: { style: OverlayStyleName; range: GeneralizedRange }[] = + []; + const afterDecorations: Decoration[] = []; + + if ( + recordedFlashCount === 0 && + finDoc != null && + finDoc !== initDoc && + hasAfterFrame + ) { + let p = 0; + while ( + p < initDoc.length && + p < finDoc.length && + initDoc[p] === finDoc[p] + ) { + p++; + } + let sfx = 0; + while ( + sfx < initDoc.length - p && + sfx < finDoc.length - p && + initDoc[initDoc.length - 1 - sfx] === finDoc[finDoc.length - 1 - sfx] + ) { + sfx++; + } + const remEnd = initDoc.length - sfx; + const insEnd = finDoc.length - sfx; + if (remEnd > p) { + duringFlashes.push({ + style: "pendingDelete" as OverlayStyleName, + range: { + type: "character", + start: toPos(initDoc, p), + end: toPos(initDoc, remEnd), + }, + }); + } + if (insEnd > p) { + afterDecorations.push({ + style: "justAdded" as OverlayStyleName, + role: "flash", + range: { + type: "character", + start: toPos(finDoc, p), + end: toPos(finDoc, insEnd), + }, + }); + } + } + + return { duringFlashes, afterDecorations }; +} diff --git a/packages/command-visualizer/src/logic/derive-overlays.ts b/packages/command-visualizer/src/logic/derive-overlays.ts new file mode 100644 index 0000000000..e729785ae5 --- /dev/null +++ b/packages/command-visualizer/src/logic/derive-overlays.ts @@ -0,0 +1,83 @@ +// Step 7 overlays — highlights + thatMark/sourceMark → decorations. Extracted +// verbatim from pipeline.ts so fixtureToCascade stays under the 250-line limit. +// Pure: reads the fixture ide/finalState objects, returns the decorations to +// append (before-frame highlights, after-frame that/source). No mutation of +// caller state. logic/ → logic/ import only. + +import type { OverlayStyleName } from "../data/decorations"; +import type { Decoration } from "../model/types"; +import type { Obj } from "./fixture-extract"; +import { asArr, asObj, pos, toGeneralizedRange } from "./fixture-extract"; + +export interface OverlayDecorations { + /** highlight decorations to append to the BEFORE frame. */ + beforeDecorations: Decoration[]; + /** that/source decorations to append to the AFTER frame. */ + afterDecorations: Decoration[]; +} + +/** + * Step 7: highlights → BEFORE decorations (painted on before frame in this + * corpus); thatMark / sourceMark → AFTER decorations (rendered as referenced). + * The after list is only populated when the step has an after frame. + */ +export function deriveOverlays(args: { + ide: Obj | null; + final: Obj | null; + hasAfterFrame: boolean; +}): OverlayDecorations { + const { ide, final, hasAfterFrame } = args; + const beforeDecorations: Decoration[] = []; + const afterDecorations: Decoration[] = []; + + // Step 7: highlights → decorations (painted on before frame in this corpus). + for (const h of asArr(ide?.highlights)) { + const ho = asObj(h); + if (!ho) { + continue; + } + const style = String(ho.style) as OverlayStyleName; + for (const r of asArr(ho.ranges)) { + const range = toGeneralizedRange(asObj(r) ?? {}); + if (range) { + beforeDecorations.push({ style, range, role: "highlight" }); + } + } + } + + // Step 7: thatMark / sourceMark → AFTER decorations (rendered as referenced). + if (hasAfterFrame) { + for (const tm of asArr(final?.thatMark)) { + const o = asObj(tm); + const cr = asObj(o?.contentRange); + if (cr) { + afterDecorations.push({ + style: "referenced", + role: "that", + range: { + type: "character", + start: pos(cr.start), + end: pos(cr.end), + }, + }); + } + } + for (const sm of asArr(final?.sourceMark)) { + const o = asObj(sm); + const cr = asObj(o?.contentRange); + if (cr) { + afterDecorations.push({ + style: "pendingModification0", + role: "source", + range: { + type: "character", + start: pos(cr.start), + end: pos(cr.end), + }, + }); + } + } + } + + return { beforeDecorations, afterDecorations }; +} diff --git a/packages/command-visualizer/src/logic/fixture-extract.ts b/packages/command-visualizer/src/logic/fixture-extract.ts new file mode 100644 index 0000000000..3c8b2194d5 --- /dev/null +++ b/packages/command-visualizer/src/logic/fixture-extract.ts @@ -0,0 +1,214 @@ +// Field-extraction helpers for the fixture → state pipeline. Pulls the +// YAML-shape coercion + marks→hats + selections + range mapping out of +// pipeline.ts so each module stays under the line ceiling. + +import type { + HatColor, + HatShape, + GeneralizedRange, +} from "@cursorless/lib-common"; +import { HAT_COLORS, Position, Range } from "@cursorless/lib-common"; +import type { Line, Token, InputHat } from "../model/columns"; +import type { YamlValue } from "./fixture-yaml"; +import { allocateHats } from "./hat-allocator"; +import { tokenizeDoc } from "./tokenize"; + +export type Obj = Record; + +export function asObj(v: YamlValue | undefined): Obj | null { + return v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : null; +} +export function asArr(v: YamlValue | undefined): YamlValue[] { + return Array.isArray(v) ? v : []; +} +export function num(v: YamlValue | undefined): number { + return typeof v === "number" ? v : Number(v); +} +export function pos(v: YamlValue | undefined): Position { + const o = asObj(v) ?? {}; + return new Position(num(o.line), num(o.character)); +} + +// Dedup note (re-verified against current signatures): @cursorless/lib-common +// exports serializedMarksToTokenHats(marks, editor), but its signature +// hard-requires a live TextEditor — it calls editor.document.offsetAt(range) +// and editor.document.getText(range) — and returns engine TokenHat[] +// (token.editor/offsets/hatRange). Our parseMarks reads plain fixture-YAML +// `{color}.{grapheme}` objects with NO editor and yields the MarkInfo render +// model buildLines() needs. buildLines() itself (tokenize → attach fixture hats +// → real allocator fill → author overrides) is genuinely ours. Adopting the +// engine helper would mean synthesizing a fake TextEditor and rewriting +// buildLines — scope balloon for no gain. Kept. + +// Step 4: a `{color}.{grapheme}` mark with its line range. +export interface MarkInfo { + key: string; + grapheme: string; + color: HatColor; + start: Position; + end: Position; +} + +export function parseMarks(marksObj: Obj | null): MarkInfo[] { + if (!marksObj) { + return []; + } + const out: MarkInfo[] = []; + for (const [key, val] of Object.entries(marksObj)) { + const range = asObj(val); + if (!range) { + continue; + } + const dot = key.indexOf("."); + // mark key must be "{color}.{grapheme}" — skip malformed entries + if (dot === -1) { + continue; + } + const colorRaw = key.slice(0, dot); + const grapheme = key.slice(dot + 1); + const color = (HAT_COLORS as readonly string[]).includes(colorRaw) + ? (colorRaw as HatColor) + : ("default" as HatColor); + out.push({ + key, + grapheme, + color, + start: pos(range.start), + end: pos(range.end), + }); + } + return out; +} + +/** Options for buildLines beyond the mark list. */ +export interface BuildLinesOptions { + /** Per-mark-key shape overrides, e.g. { "default.f": "fox" }. */ + shapeOverride?: Record; + /** + * "dense" (default): real-allocator fill over every unhatted hattable + * token — the whole image wears hats, like a live session. + * "marks-only": render exactly the fixture's recorded marks, no fill. + */ + fill?: "dense" | "marks-only"; + /** + * Position-keyed exact-hat overrides applied LAST, to any token (marked or + * fill), keyed "{line}:{startChar}". Author intent wins over both the + * fixture and the allocator; may deliberately duplicate a (grapheme, style) + * the allocator assigned elsewhere — that's on the author. + */ + hatOverride?: Record; +} + +// Steps 3 + 4 + 5: tokenize a doc and attach hats. +export function buildLines( + doc: string, + marks: MarkInfo[], + opts: BuildLinesOptions = {}, +): Line[] { + const { shapeOverride, fill = "dense", hatOverride } = opts; + const lines = tokenizeDoc(doc); + + // Pass 1: command-relevant fixture marks — exact fixture color; shape is + // "default" unless a per-fixture override says otherwise. Fixture mark keys + // ({color}.{grapheme}) carry no shape component: the recorded session's + // hats were default-shape, so "default" is the faithful rendering. + for (const mk of marks) { + // marks are single-line in corpus + if (mk.start.line !== mk.end.line) { + continue; + } + const line = lines[mk.start.line]; + if (!line) { + continue; + } + const hat: InputHat = { + color: mk.color, + shape: shapeOverride?.[mk.key] ?? "default", + }; + let target: Token | undefined = line.tokens.find( + (t) => + t.range.start === mk.start.character && + t.range.end === mk.end.character, + ); + if (!target) { + target = line.tokens.find( + (t) => + t.range.start <= mk.start.character && + mk.start.character < t.range.end, + ); + } + if (target) { + target.hat = hat; + } + } + + // Pass 2: REAL hat allocation (allocate-hats package — cursorless's own + // chooseTokenHat at SHA 42452eb). Fixture-marked tokens from pass 1 enter + // as old assignments (kept, and their colors consumed from the pool); every + // other hattable token gets the algorithm's color AND shape — shapes now + // appear only under real collision pressure instead of hash randomness. + // Skipped in "marks-only" mode: render exactly what the fixture recorded. + if (fill === "dense") { + allocateHats(lines); + } + + // Pass 3: position-keyed exact-hat overrides — author intent wins last. + if (hatOverride) { + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + for (const token of lines[lineIdx].tokens) { + const o = hatOverride[`${lineIdx}:${token.range.start}`]; + if (!o) { + continue; + } + token.hat = { + color: o.color ?? token.hat?.color ?? "default", + shape: o.shape ?? token.hat?.shape ?? "default", + }; + } + } + } + + return lines; +} + +// Selections → {cursors, selections}. anchor==active ⇒ caret; reversed normalize. +export function deriveSelections(selArr: YamlValue[]): { + cursors: Position[]; + selections: Range[]; +} { + const cursors: Position[] = []; + const selections: Range[] = []; + for (const s of selArr) { + const o = asObj(s); + if (!o) { + continue; + } + const anchor = pos(o.anchor); + const active = pos(o.active); + if (anchor.isEqual(active)) { + cursors.push(anchor); + } else { + // Range's constructor normalizes to non-reversed (start ≤ end), matching + // the previous manual anchor/active ordering. + selections.push(new Range(anchor, active)); + } + } + return { cursors, selections }; +} + +// Steps 6/7: a flash/highlight range YAML → a Decoration GeneralizedRange. +export function toGeneralizedRange(rangeObj: Obj): GeneralizedRange | null { + if (rangeObj.type === "line") { + return { + type: "line", + start: num(rangeObj.start), + // last line, INCLUSIVE per lib-common LineRange + end: num(rangeObj.end), + }; + } + return { + type: "character", + start: pos(rangeObj.start), + end: pos(rangeObj.end), + }; +} diff --git a/packages/command-visualizer/src/logic/fixture-root.ts b/packages/command-visualizer/src/logic/fixture-root.ts new file mode 100644 index 0000000000..29219ca326 --- /dev/null +++ b/packages/command-visualizer/src/logic/fixture-root.ts @@ -0,0 +1,85 @@ +// Portable resolution of the cursorless repo root and its fixture subpaths. +// +// Resolution order: +// 1. $CURSORLESS_REPO env var (explicit override — CI, alt checkouts) +// 2. $HOME/code/cursorless (sensible default, NOT a hardcoded user) +// +// Layout probe: the local repo may use either of two directory structures: +// A (mini2 fork): resources/images/hats/ + resources/fixtures/recorded/ +// B (main repo): images/hats/ + data/fixtures/recorded/ +// +// Throws a CLEAR error if the resolved root does not exist, so a misconfigured +// checkout fails loudly at startup instead of with an opaque ENOENT mid-run. +// +// Dedup note (re-verified against current signatures): @cursorless/lib-node-common +// exports getFixturesPath()/getRecordedTestsDirPath(), but getFixturesPath() +// hardcodes `path.path.join(getCursorlessRepoRoot(), "resources", "fixtures")` — the +// single layout-A shape — and getCursorlessRepoRoot() *throws* unless +// CURSORLESS_REPO_ROOT is set (a script-only helper). This module deliberately +// keeps its dual-layout probe (resources/… fork vs data/… main repo), its +// $CURSORLESS_REPO override with a working $HOME/code/cursorless default, and +// loud errors, so it resolves across both checkout shapes the renderer targets. +// Adopting lib-node-common's helpers would be a regression (layout B unreachable, +// no default root). Kept. + +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +/** Absolute path to the cursorless repo root (env override, else $HOME/code/cursorless). */ +export function cursorlessRepoRoot(): string { + // eslint-disable-next-line node/no-process-env + const fromEnv = process.env.CURSORLESS_REPO?.trim(); + const root = + fromEnv && fromEnv.length > 0 + ? fromEnv + : path.join(homedir(), "code", "cursorless"); + + if (!existsSync(root)) { + const how = fromEnv + ? `$CURSORLESS_REPO is set to "${root}"` + : `defaulted to "${root}" ($HOME/code/cursorless)`; + throw new Error( + `cursorless repo not found: ${how}, but that path does not exist.\nSet CURSORLESS_REPO to your cursorless checkout, e.g.\n CURSORLESS_REPO=/path/to/cursorless bun run verify`, + ); + } + return root; +} + +/** Detect which directory layout the repo uses and return the hats dir. */ +export function hatsRoot(): string { + const root = cursorlessRepoRoot(); + // Layout A (mini2 fork): resources/images/hats/ + const layoutA = path.join(root, "resources", "images", "hats"); + if (existsSync(layoutA)) { + return layoutA; + } + // Layout B (main repo): images/hats/ + const layoutB = path.join(root, "images", "hats"); + if (existsSync(layoutB)) { + return layoutB; + } + throw new Error( + `cursorless hat SVGs not found in "${root}".\n` + + `Expected one of:\n ${layoutA}\n ${layoutB}`, + ); +} + +/** Detect which directory layout the repo uses and return the recorded fixtures dir. */ +export function fixtureRoot(): string { + const root = cursorlessRepoRoot(); + // Layout A (mini2 fork): resources/fixtures/recorded/ + const layoutA = path.join(root, "resources", "fixtures", "recorded"); + if (existsSync(layoutA)) { + return layoutA; + } + // Layout B (main repo): data/fixtures/recorded/ + const layoutB = path.join(root, "data", "fixtures", "recorded"); + if (existsSync(layoutB)) { + return layoutB; + } + throw new Error( + `cursorless recorded fixtures not found in "${root}".\n` + + `Expected one of:\n ${layoutA}\n ${layoutB}`, + ); +} diff --git a/packages/command-visualizer/src/logic/fixture-yaml.test.ts b/packages/command-visualizer/src/logic/fixture-yaml.test.ts new file mode 100644 index 0000000000..212c9d9d59 --- /dev/null +++ b/packages/command-visualizer/src/logic/fixture-yaml.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { parseFixtureYaml } from "./fixture-yaml"; + +suite("command-visualizer/fixture-yaml", () => { + suite("parseFixtureYaml", () => { + test("returns {} for an empty string (js-yaml 5.x throws otherwise)", () => { + assert.deepEqual(parseFixtureYaml(""), {}); + }); + + test("returns {} for whitespace-only input", () => { + assert.deepEqual(parseFixtureYaml(" \n\t \n"), {}); + }); + + test("parses a block mapping into a plain object", () => { + const result = parseFixtureYaml("command:\n spokenForm: change token\n"); + assert.deepEqual(result, { command: { spokenForm: "change token" } }); + }); + + test("returns {} for a top-level scalar (not a mapping)", () => { + assert.deepEqual(parseFixtureYaml("just a string"), {}); + }); + + test("returns {} for a top-level sequence (not a mapping)", () => { + assert.deepEqual(parseFixtureYaml("- a\n- b\n"), {}); + }); + + test("preserves a literal block scalar byte-for-byte", () => { + const result = parseFixtureYaml("documentContents: |\n a\n b\n"); + assert.equal(result.documentContents, "a\nb\n"); + }); + }); +}); diff --git a/packages/command-visualizer/src/logic/fixture-yaml.ts b/packages/command-visualizer/src/logic/fixture-yaml.ts new file mode 100644 index 0000000000..c33252c910 --- /dev/null +++ b/packages/command-visualizer/src/logic/fixture-yaml.ts @@ -0,0 +1,47 @@ +// Fixture YAML reader. +// +// Was a hand-rolled parser for the recorded-fixture YAML subset (block maps, +// block scalars, block sequences, flow maps). Replaced with cursorless's own +// YAML library, `js-yaml` — the same dependency and the same `load()` entry +// point `@cursorless/lib-node-common`'s loadFixture.ts uses to read these +// fixtures. This drops ~250 lines of bespoke parser and the yaml-scalars.ts +// primitives module in favor of the battle-tested lib, while preserving the +// exported surface (`parseFixtureYaml`, `YamlValue`) that fixture-extract.ts +// and pipeline.ts depend on. +// +// Byte-exact `documentContents` (GATE 0) is preserved: js-yaml's literal block +// scalar (`|` / `|N`) handling is the reference implementation the hand-rolled +// reader was mimicking. + +import { load } from "js-yaml"; + +/** + * Recursive JSON-ish value produced by parsing a fixture YAML document. Matches + * the shape js-yaml's default schema yields for the recorded-fixture subset + * (scalars, block/flow maps, block sequences). Kept stable for downstream + * consumers (fixture-extract.ts coerces via asObj/asArr/num/pos). + */ +export type YamlValue = + | string + | number + | boolean + | null + | YamlValue[] + | { [k: string]: YamlValue }; + +/** + * Parse a full fixture YAML document into a plain object. Returns `{}` for an + * empty/null document so callers can index into it unconditionally. + */ +export function parseFixtureYaml(src: string): { [k: string]: YamlValue } { + // js-yaml 5.x throws on null/undefined input; guard so the `{}` fallback + // is actually reachable for blank or whitespace-only fixture strings. + if (!src || !src.trim()) { + return {}; + } + const value = load(src) as YamlValue | undefined; + if (value != null && typeof value === "object" && !Array.isArray(value)) { + return value as { [k: string]: YamlValue }; + } + return {}; +} diff --git a/packages/command-visualizer/src/logic/hat-allocator.test.ts b/packages/command-visualizer/src/logic/hat-allocator.test.ts new file mode 100644 index 0000000000..51976eee18 --- /dev/null +++ b/packages/command-visualizer/src/logic/hat-allocator.test.ts @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import type { HatColor } from "@cursorless/lib-common"; +import type { InputHat, Line, Token } from "../model/columns"; +import { allocateHats, cssStateHatStyles } from "./hat-allocator"; + +// Build a Line whose tokens are one-per-grapheme (the render model, tokenize.ts +// R5) with line-relative UTF-16 ranges. Spaces are emitted as their own tokens +// so offsets stay contiguous, matching what buildLines/tokenizeDoc produces. +function line(text: string): Line { + const tokens: Token[] = []; + for (let i = 0; i < text.length; i++) { + tokens.push({ text: text[i], range: { start: i, end: i + 1 } }); + } + return { tokens }; +} + +/** Attach a fixture-mark hat to the token starting at `character` on `line`. */ +function markToken(l: Line, character: number, hat: InputHat): Token { + const token = l.tokens.find((t) => t.range.start === character); + assert.ok(token, `no token at character ${character}`); + token.hat = hat; + return token; +} + +function countHatted(lines: Line[]): number { + return lines.reduce( + (n, l) => n + l.tokens.filter((t) => t.hat != null).length, + 0, + ); +} + +function snapshot(lines: Line[]) { + return lines.map((l) => + l.tokens.map((t) => + t.hat ? `${t.range.start}:${t.hat.color}/${t.hat.shape}` : "", + ), + ); +} + +suite("command-visualizer/hat-allocator", () => { + suite("cssStateHatStyles", () => { + test("keys pure colors and color-shape variants with penalty ordering", () => { + const styles = cssStateHatStyles(); + // Pure color exists and is default-shape (bare key). + assert.ok(styles.blue, "expected a bare 'blue' style"); + // Shaped variant exists and carries +1 penalty over its color. + assert.ok(styles["blue-fox"], "expected a 'blue-fox' style"); + assert.equal(styles["blue-fox"].penalty, styles.blue.penalty + 1); + // 'default' color has the lowest penalty. + assert.equal(styles.default.penalty, 0); + }); + }); + + suite("allocateHats", () => { + test("assigns at least one hat over a couple of words", () => { + const lines = [line("value width")]; + allocateHats(lines); + assert.ok( + countHatted(lines) >= 1, + "expected the real allocator to place at least one hat", + ); + }); + + test("hats land on real graphemes, with a valid palette style", () => { + const lines = [line("alpha bravo")]; + allocateHats(lines); + const styles = cssStateHatStyles(); + const validColors = new Set( + Object.keys(styles).map((k) => k.split("-")[0]), + ); + for (const l of lines) { + for (const t of l.tokens) { + if (t.hat == null) { + continue; + } + // A hatted token must be a non-whitespace grapheme. + assert.ok( + t.text.trim().length > 0, + `hat landed on whitespace token "${t.text}"`, + ); + // Its color must be a real palette color. + assert.ok( + validColors.has(t.hat.color), + `hat color "${t.hat.color}" not in palette`, + ); + } + } + }); + + test("a pre-attached fixture-mark hat keeps its exact color", () => { + // Two words; pin the first grapheme of "world" to a specific color, then + // let the allocator fill the rest. The pinned mark must survive with the + // same color it went in with. + const l = line("hello world"); + const pinnedColor: HatColor = "yellow"; + // char index of "w" + const worldStart = "hello ".length; + const marked = markToken(l, worldStart, { + color: pinnedColor, + shape: "default", + }); + + allocateHats([l]); + + assert.ok(marked.hat, "pinned mark lost its hat entirely"); + assert.equal( + marked.hat.color, + pinnedColor, + `pinned mark color changed from ${pinnedColor} to ${marked.hat.color}`, + ); + }); + + test("multiple pinned marks each keep their color across lines", () => { + const l0 = line("red apple"); + const l1 = line("blue sky"); + const m0 = markToken(l0, 0, { color: "red", shape: "default" }); + const m1 = markToken(l1, 0, { color: "blue", shape: "default" }); + + allocateHats([l0, l1]); + + assert.equal(m0.hat?.color, "red", "first pinned mark changed color"); + assert.equal(m1.hat?.color, "blue", "second pinned mark changed color"); + }); + + test("is deterministic: same input yields the same assignment", () => { + const build = () => [line("value width height")]; + const a = build(); + const b = build(); + allocateHats(a); + allocateHats(b); + + assert.deepEqual(snapshot(a), snapshot(b)); + }); + + test("empty document does not throw and hats nothing", () => { + const lines = [line("")]; + allocateHats(lines); + assert.equal(countHatted(lines), 0); + }); + }); +}); diff --git a/packages/command-visualizer/src/logic/hat-allocator.ts b/packages/command-visualizer/src/logic/hat-allocator.ts new file mode 100644 index 0000000000..025f9e02e2 --- /dev/null +++ b/packages/command-visualizer/src/logic/hat-allocator.ts @@ -0,0 +1,243 @@ +// Hat allocation via cursorless's REAL engine (@cursorless/lib-engine), driven +// by an in-memory document — the algorithm is imported directly, not copied in. +// +// The visualizer's render model (columns.ts) is one Token per GRAPHEME with +// line-relative UTF-16 offsets. This module reconstructs the document those +// lines represent, hands it to the real cursorless allocator, and maps the +// returned hats back onto the render tokens. +// +// How it works: +// 1. Reconstruct the document text: each Line's tokens joined, lines joined +// by "\n". +// 2. Build a FakeIDE + InMemoryTextEditor holding that text, with the cursor +// as the sole selection (its `active` is the proximity reference point the +// engine ranks tokens against) and the whole document visible. +// 3. Instantiate the real TokenGraphemeSplitter (cursorless's own grapheme +// splitter, IDE-configured) and call the real allocateHats(). The engine +// tokenizes the visible ranges with cursorless's own tokenizer +// (getTokensInRange), ranks tokens by distance from the cursor, and +// assigns at most one hat per token. +// 4. Fixture marks — graphemes that already carry a `.hat` from buildLines +// pass 1 — are PINNED via `forceTokenHats`: for each mark we find the +// engine token covering its position (getTokensInRange, so token identity +// matches what the engine produces internally) and force that token's hat +// to the mark's exact style and grapheme. The engine's chooseTokenHat +// applies forced hats first, unconditionally, so a pinned mark keeps its +// fixture color and position. +// 5. Each returned TokenHat is mapped back to the render token at its +// hatRange (line + character) and gets `.hat = styleToHat(style)`. +// +// The palette / penalty map (cssStateHatStyles + colorPenalty) is the +// visualizer's OWN — the full color x shape space so shapes appear only under +// genuine collision pressure, exactly like a real cursorless session. +// +// Determinism: the engine allocator is pure (no Date/random); the same lines + +// marks + cursor produce identical assignments. + +import type { + HatColor, + HatShape, + HatStyleMap, + TokenHat, +} from "@cursorless/lib-common"; +import { + FakeIDE, + HatStability, + InMemoryTextEditor, + Position, + Range, + Selection, + HAT_COLORS, + HAT_SHAPES, +} from "@cursorless/lib-common"; +import { + allocateHats as allocateHatsReal, + getTokensInRange, + TokenGraphemeSplitter, +} from "@cursorless/lib-engine"; +import type { Line, Token as RenderToken } from "../model/columns"; + +// --------------------------------------------------------------------------- +// Style map: our full palette x (default + 10 shapes), penalty-ordered the way +// cursorless orders its own map — default color 0, named colors 1, user colors +// 2, +1 for a shape. Pure colors are inserted BEFORE shaped variants so free +// pure colors win penalty ties in the allocator's candidate ordering (matches +// real cursorless, where shapes appear only after the color pool drains). +// --------------------------------------------------------------------------- + +function colorPenalty(color: HatColor): number { + if (color === "default") { + return 0; + } + return color.startsWith("userColor") ? 2 : 1; +} + +export function cssStateHatStyles(): HatStyleMap { + const out: HatStyleMap = {}; + for (const color of HAT_COLORS) { + out[color] = { penalty: colorPenalty(color) }; + } + for (const color of HAT_COLORS) { + for (const shape of HAT_SHAPES) { + // bare color IS the default shape + if (shape === "default") { + continue; + } + out[`${color}-${shape}`] = { penalty: colorPenalty(color) + 1 }; + } + } + return out; +} + +/** Split an allocator style name back into our (color, shape) pair. */ +function styleToHat(styleName: string): { color: HatColor; shape: HatShape } { + const dash = styleName.indexOf("-"); + if (dash === -1) { + return { color: styleName as HatColor, shape: "default" }; + } + return { + color: styleName.slice(0, dash) as HatColor, + shape: styleName.slice(dash + 1) as HatShape, + }; +} + +/** The cssStateHatStyles key for a (color, shape) pair (bare color = default). */ +function hatStyleName(color: HatColor, shape: HatShape | undefined): string { + return shape == null || shape === "default" ? color : `${color}-${shape}`; +} + +/** A fixture-marked render token together with its line index. */ +interface Mark { + lineIdx: number; + token: RenderToken; + color: HatColor; + shape: HatShape | undefined; +} + +// --------------------------------------------------------------------------- +// Main entry — called by fixture-extract.buildLines after pass 1 has attached +// fixture-mark hats to specific grapheme render tokens. Mutates `lines` in +// place, setting `.hat` on the render token each allocated hat lands on. +// --------------------------------------------------------------------------- + +export function allocateHats( + lines: Line[], + cursor: Position = new Position(0, 0), +): void { + // (1) Reconstruct the document text the render model represents. + const content = lines + .map((line) => line.tokens.map((t) => t.text).join("")) + .join("\n"); + + // (2) In-memory editor: cursor as the sole selection, whole document visible. + const ide = new FakeIDE(); + const editor = new InMemoryTextEditor({ + ide, + languageId: "plaintext", + content, + selections: [ + new Selection( + cursor.line, + cursor.character, + cursor.line, + cursor.character, + ), + ], + // visibleRanges omitted → defaults to the whole document range. + }); + + // (3) Real cursorless grapheme splitter (IDE-configured). + const tokenGraphemeSplitter = new TokenGraphemeSplitter(ide); + + // (4) Collect fixture marks from the render tokens carrying a pass-1 hat. + const marks: Mark[] = []; + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]; + for (const token of line.tokens) { + if (token.hat) { + marks.push({ + lineIdx, + token, + color: token.hat.color, + shape: token.hat.shape, + }); + } + } + } + + // Discover the engine tokens once (whole document), so forced-hat token + // identity matches what the engine produces internally. + const engineTokens = getTokensInRange(ide, editor, editor.document.range); + + // Build forceTokenHats: for each mark, force the covering engine token to the + // mark's exact color/shape, anchored on the mark's grapheme. + const forceTokenHats: TokenHat[] = []; + const forcedTokenKeys = new Set(); + for (const mark of marks) { + // line-relative char offset + const markStart = mark.token.range.start; + const covering = engineTokens.find( + (t) => + t.range.start.line === mark.lineIdx && + t.range.start.character <= markStart && + markStart < t.range.end.character, + ); + if (covering == null) { + // Whitespace-only or otherwise untokenized position — nothing to pin. + continue; + } + // One forced hat per engine token (a token wears at most one hat). If two + // marks fall in the same token, the first wins — matches "one mark pins the + // whole segment" from the previous word-segment model. + const tokenKey = `${covering.offsets.start}:${covering.offsets.end}`; + if (forcedTokenKeys.has(tokenKey)) { + continue; + } + forcedTokenKeys.add(tokenKey); + + const styleName = hatStyleName(mark.color, mark.shape); + const grapheme = tokenGraphemeSplitter.normalizeGrapheme(mark.token.text); + // Grapheme range within the document (line-relative positions), matching + // the render token span so map-back lands on this exact token. + const hatRange = new Range( + mark.lineIdx, + mark.token.range.start, + mark.lineIdx, + mark.token.range.end, + ); + forceTokenHats.push({ + hatStyle: styleName, + grapheme, + token: covering, + hatRange, + }); + } + + // (5) Run the real allocator. + const tokenHats = allocateHatsReal({ + ide, + tokenGraphemeSplitter, + enabledHatStyles: cssStateHatStyles(), + forceTokenHats, + oldTokenHats: [], + hatStability: HatStability.balanced, + activeTextEditor: editor, + visibleTextEditors: [editor], + }); + + // (6) Map each returned hat back onto the render token at its hatRange. + for (const tokenHat of tokenHats) { + const { line, character } = tokenHat.hatRange.start; + const renderLine = lines[line]; + if (renderLine == null) { + continue; + } + const target = renderLine.tokens.find((t) => t.range.start === character); + if (target == null) { + continue; + } + target.hat = styleToHat(tokenHat.hatStyle); + } + + ide.exit(); +} diff --git a/packages/command-visualizer/src/logic/pipeline-types.ts b/packages/command-visualizer/src/logic/pipeline-types.ts new file mode 100644 index 0000000000..44ca841563 --- /dev/null +++ b/packages/command-visualizer/src/logic/pipeline-types.ts @@ -0,0 +1,38 @@ +// Shared pipeline types — the contract between the three named stages +// (parseFixture → tokenizeStates → buildRenderObject). Kept in its own module +// so stage files can import them without a circular pipeline ⇄ build-render +// edge. logic/ → logic/ + model/ + data/ imports only. + +import type { HatColor, HatShape } from "@cursorless/lib-common"; +import type { Theme } from "../data/colors"; +import type { Frame } from "../model/types"; +import type { Obj } from "./fixture-extract"; + +export interface PipelineOptions { + shapeOverride?: Record; + theme?: Theme; + tabSize?: number; + /** "dense" (default) = real-allocator fill everywhere; "marks-only" = exactly the recorded marks. */ + fill?: "dense" | "marks-only"; + /** Position-keyed exact-hat overrides, keyed "{line}:{startChar}" — applied last, wins over everything. */ + hatOverride?: Record; +} + +/** Stage-1 output: the parsed fixture plus everything stages 2/3 read from it. */ +export interface ParsedFixture { + theme: Theme; + tabSize: number; + meta: { spokenForm?: string; action?: string; fixture: string }; + initial: Obj | null; + final: Obj | null; + ide: Obj | null; + /** clipboard visible on a given state, per action semantics. */ + clipFor: (state: "before" | "after") => string | undefined; +} + +/** Stage-2 output: the tokenized before/after frames (mutated by stage 3). */ +export interface TokenizedStates { + frames: Frame[]; + beforeFrame: Frame; + afterFrame: Frame | null; +} diff --git a/packages/command-visualizer/src/logic/pipeline.ts b/packages/command-visualizer/src/logic/pipeline.ts new file mode 100644 index 0000000000..6998bc6a06 --- /dev/null +++ b/packages/command-visualizer/src/logic/pipeline.ts @@ -0,0 +1,170 @@ +// Fixture → state pipeline. Pure, deterministic. +// yml → frames → tokenize → marks→hats → synthetic shape → flashes→overlays +// → that/source/highlights → CascadeState. The render contract is unchanged +// downstream. +// +// The public `fixtureToCascade` is a thin composition of three named stages: +// 1. parseFixture — YAML text → ParsedFixture (doc + meta + clipboard) +// 2. tokenizeStates — ParsedFixture → before/after Frames (lines + hats) +// 3. buildRenderObject — Frames → CascadeState (flashes, during, overlays) +// Each stage is exported so a reviewer can read the progression top-to-bottom. +// Stage 3 lives in ./build-render-object; its types in ./pipeline-types. + +import { readFileSync } from "node:fs"; +import type { Theme } from "../data/colors"; +import type { CascadeState, Frame } from "../model/types"; +import { buildRenderObject } from "./build-render-object"; +import { + asArr, + asObj, + buildLines, + deriveSelections, + parseMarks, +} from "./fixture-extract"; +import { fixtureRoot } from "./fixture-root"; +import { parseFixtureYaml } from "./fixture-yaml"; +import type { + ParsedFixture, + PipelineOptions, + TokenizedStates, +} from "./pipeline-types"; + +export { buildRenderObject } from "./build-render-object"; +export type { + ParsedFixture, + PipelineOptions, + TokenizedStates, +} from "./pipeline-types"; + +// Resolved lazily: fixtureRoot() throws when no cursorless checkout exists, +// which must not fire at module load in serverless (the API path reads +// bundled fixtures and never touches disk). +let fixtureRootCache: string | undefined; +function fixtureRootLazy(): string { + return (fixtureRootCache ??= fixtureRoot()); +} + +// ── Stage 1: get what to render ────────────────────────────────────────────── +/** Parse fixture YAML and resolve meta + per-state clipboard visibility. */ +export function parseFixture( + src: string, + fixtureRel: string, + opts: PipelineOptions = {}, +): ParsedFixture { + const doc = parseFixtureYaml(src); + const theme: Theme = opts.theme ?? "dark"; + const tabSize = opts.tabSize ?? 4; + + const command = asObj(doc.command); + const action = asObj(command?.action); + const meta = { + spokenForm: + typeof command?.spokenForm === "string" ? command.spokenForm : undefined, + action: typeof action?.name === "string" ? action.name : undefined, + fixture: fixtureRel, + }; + + const initial = asObj(doc.initialState); + const final = asObj(doc.finalState); + + // Clipboard visibility per state — ported from cursorless's + // VisualizerMetadata.tsx: cut/copy PRODUCE clipboard (visible on AFTER only); + // paste CONSUMES it (visible on all states). + const actionName = meta.action ?? ""; + const initClip = + typeof initial?.clipboard === "string" ? initial.clipboard : undefined; + const finalClip = + typeof final?.clipboard === "string" ? final.clipboard : undefined; + const clipProduced = + actionName === "cutToClipboard" || actionName === "copyToClipboard"; + const clipConsumed = actionName === "pasteFromClipboard"; + const clipFor = (state: "before" | "after"): string | undefined => { + if (clipProduced) { + return state === "after" ? finalClip : undefined; + } + if (clipConsumed) { + return initClip; + } + return undefined; + }; + + return { theme, tabSize, meta, initial, final, ide: asObj(doc.ide), clipFor }; +} + +// ── Stage 2: tokenize each step ────────────────────────────────────────────── +/** Tokenize the before (+ after) documents into Frames with lines + hats. */ +export function tokenizeStates( + parsed: ParsedFixture, + opts: PipelineOptions = {}, +): TokenizedStates { + const { initial, final, clipFor } = parsed; + const buildOpts = { + shapeOverride: opts.shapeOverride, + fill: opts.fill, + hatOverride: opts.hatOverride, + }; + const initMarks = parseMarks(asObj(initial?.marks)); + + // Step 2: BEFORE frame. + const beforeLines = buildLines( + (initial?.documentContents as string) ?? "", + initMarks, + buildOpts, + ); + const beforeSel = deriveSelections(asArr(initial?.selections)); + const beforeFrame: Frame = { + role: "before", + lines: beforeLines, + cursors: beforeSel.cursors, + selections: beforeSel.selections, + decorations: [], + command: parsed.meta.spokenForm, + clipboard: clipFor("before"), + }; + + const frames: Frame[] = [beforeFrame]; + + // AFTER frame (omit if no finalState — error fixtures). + let afterFrame: Frame | null = null; + if (final && typeof final.documentContents === "string") { + // Final docs ship no marks; re-tokenize identically (no hats on after). + const afterLines = buildLines(final.documentContents, [], buildOpts); + const afterSel = deriveSelections(asArr(final.selections)); + afterFrame = { + role: "after", + lines: afterLines, + cursors: afterSel.cursors, + selections: afterSel.selections, + decorations: [], + clipboard: clipFor("after"), + }; + frames.push(afterFrame); + } + + return { frames, beforeFrame, afterFrame }; +} + +/** Full pipeline: fixture YAML text → CascadeState (stages 1 → 2 → 3). */ +export function fixtureToCascade( + src: string, + fixtureRel: string, + opts: PipelineOptions = {}, +): CascadeState { + // 1. get what to render + const parsed = parseFixture(src, fixtureRel, opts); + // 2. tokenize each step + const tokenized = tokenizeStates(parsed, opts); + // 3. generate render object + return buildRenderObject(parsed, tokenized, opts); +} + +/** Convenience: load a recorded fixture by relative path. */ +export function loadFixtureCascade( + fixtureRel: string, + opts: PipelineOptions = {}, +): CascadeState { + const src = readFileSync(`${fixtureRootLazy()}/${fixtureRel}`, "utf8"); + return fixtureToCascade(src, fixtureRel, opts); +} + +export { fixtureRootLazy as fixtureRootPath }; diff --git a/packages/command-visualizer/src/logic/tokenize.ts b/packages/command-visualizer/src/logic/tokenize.ts new file mode 100644 index 0000000000..6bf6f77f2d --- /dev/null +++ b/packages/command-visualizer/src/logic/tokenize.ts @@ -0,0 +1,98 @@ +// Grapheme tokenizer + inverse detokenizer. +// +// Fixtures ship RAW `documentContents`, not tokens, and every column must be +// owned by exactly one token. Grapheme-level hatting splits each line into ONE +// TOKEN PER GRAPHEME — letters AND each individual symbol / operator / paren +// (`=>`, `(`, `)`, `:`, `;`, `"`, …) — exactly like real cursorless +// (GRAPHEME_SPLIT_REGEX, tokenGraphemeSplitter.ts). Whitespace runs between +// graphemes become their own (non-hattable) tokens. Each token carries its +// UTF-16 line offsets, so the grapheme/column math runs unchanged. +// +// Because each non-whitespace token is now a single grapheme, fixture-extract +// can attach a hat to EVERY grapheme (one hat per token, anchored at the +// token's first — and only — grapheme), matching cursorless's dense hatting. +// +// GATE 0: detokenize(tokenize(doc)) === doc BYTE-FOR-BYTE. +// Detokenize concatenates token text in order; since the union of grapheme + +// whitespace tokens partitions the line with no gaps or overlaps, the roundtrip +// stays byte-exact. The roundtrip test (src/verify-roundtrip-doc.ts) proves it. + +import { GRAPHEME_SPLIT_REGEX } from "@cursorless/lib-engine"; +import type { Line, Token } from "../model/columns"; + +// Cursorless grapheme splitter: a base letter + its combining marks is ONE +// grapheme; each number / punctuation / symbol is its own grapheme. The regex +// is imported from @cursorless/lib-engine (GRAPHEME_SPLIT_REGEX) — no clone. + +/** + * Tokenize one line's text into per-grapheme tokens (plus whitespace tokens). + * `\n` is NEVER part of a line (the doc is pre-split on `\n`). + * Every UTF-16 offset of the line belongs to exactly one token. + * + * Any code unit the grapheme regex does not match (whitespace, control chars, + * lone surrogates) is emitted as its own single-code-unit token so the line is + * fully partitioned and detokenize is byte-exact. + */ +export function tokenizeLine(text: string): Token[] { + const tokens: Token[] = []; + // Fresh RegExp per call: GRAPHEME_SPLIT_REGEX is a shared /gu instance whose + // lastIndex must not leak across tokenizeLine calls. + const re = new RegExp(GRAPHEME_SPLIT_REGEX, "u"); + let m: RegExpExecArray | null; + let last = 0; + + const emitGapAsTokens = (from: number, to: number) => { + // Coalesce a run of unmatched code units into whitespace/other tokens. + // Whitespace is grouped into a single run (never hattable); any non-ws gap + // char is emitted as its own token. In practice gaps are whitespace. + let i = from; + while (i < to) { + if (/\s/u.test(text[i])) { + let j = i; + while (j < to && /\s/u.test(text[j])) { + j++; + } + tokens.push({ text: text.slice(i, j), range: { start: i, end: j } }); + i = j; + } else { + tokens.push({ text: text[i], range: { start: i, end: i + 1 } }); + i++; + } + } + }; + + while ((m = re.exec(text)) != null) { + if (m.index > last) { + emitGapAsTokens(last, m.index); + } + tokens.push({ + text: m[0], + range: { start: m.index, end: m.index + m[0].length }, + }); + last = m.index + m[0].length; + // guard zero-width + if (m[0].length === 0) { + re.lastIndex++; + } + } + if (last < text.length) { + emitGapAsTokens(last, text.length); + } + return tokens; +} + +/** Tokenize a whole document into Line[]. Splits on `\n`. */ +export function tokenizeDoc(doc: string): Line[] { + const rawLines = doc.split("\n"); + return rawLines.map((text) => ({ tokens: tokenizeLine(text) })); +} + +/** Inverse: concatenate a line's token text back to the original line string. */ +export function detokenizeLine(line: Line): string { + return line.tokens.map((t) => t.text).join(""); +} + +/** Inverse of tokenizeDoc: rejoin lines with `\n`. Must be byte-exact. */ +export function detokenizeDoc(lines: Line[]): string { + return lines.map(detokenizeLine).join("\n"); +} diff --git a/packages/command-visualizer/src/model/columns.ts b/packages/command-visualizer/src/model/columns.ts new file mode 100644 index 0000000000..280957d04d --- /dev/null +++ b/packages/command-visualizer/src/model/columns.ts @@ -0,0 +1,204 @@ +// Column model. +// Converts each line's UTF-16 token stream into an ordered list of VISUAL columns. +// Build-time only; no render-time logic. +// +// - Graphemes per cursorless GRAPHEME_SPLIT_REGEX. +// - Tab → next tabSize stop. +// - East-Asian Wide/Fullwidth glyph = 2 columns. + +import type { HatColor, HatShape } from "@cursorless/lib-common"; +import { GRAPHEME_SPLIT_REGEX } from "@cursorless/lib-engine"; + +// Cursorless's grapheme splitter regex, imported from @cursorless/lib-engine +// (no clone): a base letter + its combining marks is ONE grapheme; +// numbers/punct/symbols are each their own grapheme. + +export interface InputHat { + color: HatColor; + shape: HatShape; + /** grapheme index within the token; default 0 */ + anchorGrapheme?: number; +} + +export interface Token { + text: string; + // UTF-16 offsets within the line + range: { start: number; end: number }; + hat?: InputHat | null; +} + +export interface Line { + tokens: Token[]; +} + +/** One emitted visual column (or multi-column cell for a wide glyph / tab). */ +export interface Column { + /** display text for the cell (a grapheme, or "" for blank tab filler) */ + text: string; + /** visual column index (first column of this cell) */ + col: number; + /** number of columns this cell spans (1, 2, or a tab advance) */ + width: number; + /** UTF-16 char index of this cell's first code unit within the line */ + charIndex: number; + isAnchor: boolean; + hatColor?: HatColor; + hatShape?: HatShape; +} + +// East_Asian_Width Wide (W) + Fullwidth (F) ranges. Covers CJK, Hangul, +// fullwidth forms, kana, common emoji presentation. Sufficient for the +// column-model torture test; extend the table if a fixture needs more. +// oxlint-disable unicorn/numeric-separators-style,unicorn/number-literal-case +const WIDE_RANGES: ReadonlyArray = [ + // Hangul Jamo + [0x1100, 0x115f], + // angle brackets + [0x2329, 0x232a], + // CJK radicals .. symbols + [0x2e80, 0x303e], + // Hiragana, Katakana, CJK symbols/punct + [0x3041, 0x33ff], + // CJK Ext A + [0x3400, 0x4dbf], + // CJK Unified + [0x4e00, 0x9fff], + // Yi + [0xa000, 0xa4cf], + // Hangul Syllables + [0xac00, 0xd7a3], + // CJK Compatibility Ideographs + [0xf900, 0xfaff], + // vertical forms + [0xfe10, 0xfe19], + // CJK compat / small forms + [0xfe30, 0xfe6f], + // Fullwidth Forms + [0xff00, 0xff60], + // Fullwidth signs + [0xffe0, 0xffe6], + // emoji + emoticons + [0x1f300, 0x1f64f], + // supplemental symbols/pictographs + [0x1f900, 0x1f9ff], + // CJK Ext B+ (SIP/TIP) + [0x20000, 0x3fffd], +]; +// oxlint-enable unicorn/numeric-separators-style,unicorn/number-literal-case + +function isWideCodePoint(cp: number): boolean { + for (const [lo, hi] of WIDE_RANGES) { + if (cp >= lo && cp <= hi) { + return true; + } + if (cp < lo) { + break; + } + } + return false; +} + +/** Display width of a grapheme cluster: 2 if its base code point is EAW W/F, else 1. */ +export function graphemeWidth(grapheme: string): number { + const cp = grapheme.codePointAt(0); + if (cp === undefined) { + return 1; + } + return isWideCodePoint(cp) ? 2 : 1; +} + +interface GraphemeUnit { + text: string; + /** char index within the token text */ + offset: number; +} + +/** Split a token's text into grapheme clusters, tracking each one's char offset. */ +export function splitGraphemes(text: string): GraphemeUnit[] { + const out: GraphemeUnit[] = []; + const re = new RegExp(GRAPHEME_SPLIT_REGEX, "u"); + let m: RegExpExecArray | null; + let lastIndex = 0; + while ((m = re.exec(text)) != null) { + // Emit any chars the regex skipped (e.g. whitespace) as single cells so + // every column of every line is owned by exactly one cell. + if (m.index > lastIndex) { + for (let i = lastIndex; i < m.index; i++) { + out.push({ text: text[i], offset: i }); + } + } + out.push({ text: m[0], offset: m.index }); + lastIndex = m.index + m[0].length; + // guard zero-width + if (m[0].length === 0) { + re.lastIndex++; + } + } + for (let i = lastIndex; i < text.length; i++) { + out.push({ text: text[i], offset: i }); + } + return out; +} + +/** + * Expand a line's tokens into ordered visual columns. + * Resolves each hat's anchor to a computed visual column (never a raw char index). + */ +export function expandColumns(line: Line, tabSize: number): Column[] { + const cols: Column[] = []; + let col = 0; + + for (const token of line.tokens) { + const graphemes = splitGraphemes(token.text); + const hat = token.hat ?? undefined; + const anchorIdx = hat?.anchorGrapheme ?? 0; + + for (let gi = 0; gi < graphemes.length; gi++) { + const g = graphemes[gi]; + const charIndex = token.range.start + g.offset; + // Flow-narrowing: `anchorHat` is the token's hat only on the anchor + // grapheme, else undefined. Deriving it once lets the compiler narrow + // `anchorHat?.color`/`.shape` without a non-null assertion. + const anchorHat = gi === anchorIdx ? hat : undefined; + const isAnchor = anchorHat !== undefined; + + if (g.text === "\t") { + const advance = tabSize - (col % tabSize) || tabSize; + cols.push({ + text: "", + col, + width: advance, + charIndex, + isAnchor, + hatColor: anchorHat?.color, + hatShape: anchorHat?.shape, + }); + col += advance; + continue; + } + + const w = graphemeWidth(g.text); + cols.push({ + text: g.text, + col, + width: w, + charIndex, + isAnchor, + hatColor: anchorHat?.color, + hatShape: anchorHat?.shape, + }); + col += w; + } + } + + return cols; +} + +/** Total visual columns in a line. */ +export function lineWidth(cols: Column[]): number { + if (cols.length === 0) { + return 0; + } + const last = cols[cols.length - 1]; + return last.col + last.width; +} diff --git a/packages/command-visualizer/src/model/overlays.ts b/packages/command-visualizer/src/model/overlays.ts new file mode 100644 index 0000000000..5381db6c3e --- /dev/null +++ b/packages/command-visualizer/src/model/overlays.ts @@ -0,0 +1,157 @@ +// Overlay resolution. Maps a frame's decorations + selection onto the COLUMN +// grid (column mapping, not raw char index), resolving single-winner precedence +// per cell (selection < highlight < flash, last wins). Also resolves full-width +// line-range bands. + +import type { Range } from "@cursorless/lib-common"; +import { isLineRange } from "@cursorless/lib-common"; +import type { OverlayStyleName } from "../data/decorations"; +import { overlayPrecedence } from "../data/decorations"; +import type { Column } from "./columns"; +import { expandColumns } from "./columns"; +import type { Decoration, Frame, OverlayRole } from "./types"; + +// Role sub-rank, used to break style-precedence ties: a REAL ide.flash outranks +// a DERIVED that/source overlay on the +// same range, so e.g. justAdded (flash) wins over a thatMark→referenced on the +// inserted text. selection < highlight < {derived that/source} < flash. +function roleRank(role: OverlayRole | "selection"): number { + if (role === "selection") { + return 0; + } + if (role === "flash") { + return 3; + } + if (role === "highlight") { + return 1; + } + // that | source | scope:* (derived) + return 2; +} + +export interface CellOverlay { + /** the single winning background style for this cell (or null) */ + winner: OverlayStyleName | "selection" | null; + /** all overlays present (for data-flash-stack test visibility) */ + stack: (OverlayStyleName | "selection")[]; +} + +export interface LineOverlay { + /** per visual column -> overlay (indexed by Column.col start) */ + byCol: Map; + /** full-width line-range band style, if this line is in any line decoration */ + lineFlash: OverlayStyleName | null; +} + +/** Columns covered by a half-open character range on a given line. */ +function colsForCharRange( + cols: Column[], + lineIdx: number, + firstLine: number, + startCh: number, + lastLine: number, + endCh: number, +): number[] { + if (lineIdx < firstLine || lineIdx > lastLine) { + return []; + } + const loCh = lineIdx === firstLine ? startCh : 0; + const hiCh = lineIdx === lastLine ? endCh : Infinity; + const out: number[] = []; + for (const c of cols) { + // a cell starts at charIndex; treat it as covering [charIndex, nextCharIndex) + if (c.charIndex >= loCh && c.charIndex < hiCh) { + out.push(c.col); + } + } + return out; +} + +/** Resolve all overlays for one line of a frame. */ +function resolveLine( + cols: Column[], + lineIdx: number, + selections: Range[], + decorations: Decoration[], +): LineOverlay { + const byCol = new Map(); + // track the winning (stylePrec, roleRank) tuple per column + const winRank = new Map(); + + const add = ( + col: number, + style: OverlayStyleName | "selection", + role: OverlayRole | "selection", + ) => { + let cell = byCol.get(col); + if (!cell) { + cell = { winner: null, stack: [] }; + byCol.set(col, cell); + } + cell.stack.push(style); + // lexicographic rank: style precedence dominates, role breaks ties. + const rank = overlayPrecedence(style) * 10 + roleRank(role); + const prev = winRank.get(col); + if (prev === undefined || rank >= prev) { + // last-wins at equal rank + cell.winner = style; + winRank.set(col, rank); + } + }; + + // selection (precedence 0) + for (const sel of selections) { + const a = sel.start; + const b = sel.end; + for (const col of colsForCharRange( + cols, + lineIdx, + a.line, + a.character, + b.line, + b.character, + )) { + add(col, "selection", "selection"); + } + } + + // character-range decorations (highlight prec 1, flash prec 2) + let lineFlash: OverlayStyleName | null = null; + for (const dec of decorations) { + if (isLineRange(dec.range)) { + if (lineIdx >= dec.range.start && lineIdx <= dec.range.end) { + // last line decoration wins + lineFlash = dec.style; + } + continue; + } + const r = dec.range; + for (const col of colsForCharRange( + cols, + lineIdx, + r.start.line, + r.start.character, + r.end.line, + r.end.character, + )) { + add(col, dec.style, dec.role); + } + } + + return { byCol, lineFlash }; +} + +/** Resolve overlays for every line of a frame. Returns a per-line array. */ +export function resolveFrameOverlays( + frame: Frame, + tabSize: number, +): LineOverlay[] { + return frame.lines.map((line, i) => + resolveLine( + expandColumns(line, tabSize), + i, + frame.selections, + frame.decorations, + ), + ); +} diff --git a/packages/command-visualizer/src/model/timeline.ts b/packages/command-visualizer/src/model/timeline.ts new file mode 100644 index 0000000000..eba44d32bb --- /dev/null +++ b/packages/command-visualizer/src/model/timeline.ts @@ -0,0 +1,66 @@ +// Weighted step timeline. Frame phase names: +// +// pre 500ms pre-gif bumper (initial state, no command) +// step0.initial 2000ms +// step0.during[] 1000ms pre-edit flashes, SEQUENCED: reference-class +// flashes first, deletion flashes second. (Real +// cursorless fires pre-edit flashes in PARALLEL — +// Remove awaits pendingDelete, Bring awaits +// referenced+pendingModification0 together, all +// BEFORE the edit; justAdded fires after. The +// sequencing here is a deliberate pedagogical +// refinement of that parallel reality.) +// step0.final 2000ms == step1.initial in a chain (merged frame, +// step1.during[] backward hat flow) +// ... +// stepN.final 2000ms +// reset/post 500ms seamless-loop post-gif bumper +// +// EVERY command occupies the same time scope (initial + during + final), +// whether it has zero or multiple flash states — the during phase is +// structural, with the reference sub-window always reserved. + +import type { Frame } from "./types"; + +export const INITIAL_MS = 2000; +export const DURING_MS = 1000; +/** Pre-gif / post-gif breathing room (the pre frame and the reset frame). */ +export const BUMPER_MS = 500; + +export function frameDurMs(frame: Frame): number { + if (frame.durMs != null) { + return frame.durMs; + } + if (frame.pre || frame.reset) { + return BUMPER_MS; + } + return frame.role === "during" ? DURING_MS : INITIAL_MS; +} + +export interface Timeline { + totalMs: number; + /** Per-frame [startMs, endMs). */ + startMs: number[]; + endMs: number[]; + /** Per-frame [startFrac, endFrac) of the whole timeline (0..1). */ + startFrac: number[]; + endFrac: number[]; +} + +export function timelineOf(frames: readonly Frame[]): Timeline { + const startMs: number[] = []; + const endMs: number[] = []; + let t = 0; + for (const f of frames) { + startMs.push(t); + t += frameDurMs(f); + endMs.push(t); + } + return { + totalMs: t, + startMs, + endMs, + startFrac: startMs.map((s) => s / t), + endFrac: endMs.map((e) => e / t), + }; +} diff --git a/packages/command-visualizer/src/model/types.ts b/packages/command-visualizer/src/model/types.ts new file mode 100644 index 0000000000..695550dd25 --- /dev/null +++ b/packages/command-visualizer/src/model/types.ts @@ -0,0 +1,66 @@ +// Package-defined render-model types (the multi-frame cascade schema). A FRAME +// is one `.cl-editor` surface; a cascade wraps an ordered list of frames plus a +// decoration overlay layer. +// +// MAINTAINERS: these container types are specific to this renderer and have no +// current home in cursorless (audited — see UPSTREAM_REUSE.md). Their FIELD +// types already come from `@cursorless/lib-common` (`Position`, `Range`, +// `GeneralizedRange`). `Decoration` overlaps lib-common's `FlashDescriptor` in +// shape but that is editor-coupled + flash-only. Decide whether any of these +// warrant promotion into a shared package; they are isolated here to make that +// call easy. + +import type { Position, Range, GeneralizedRange } from "@cursorless/lib-common"; +import type { Theme } from "../data/colors"; +import type { OverlayStyleName } from "../data/decorations"; +import type { Line } from "./columns"; + +export type FrameRole = "before" | "during" | "after"; + +export type OverlayRole = + | "flash" + | "highlight" + | "that" + | "source" + | `scope:${string}`; + +// GeneralizedRange (character | line) comes from @cursorless/lib-common. +// A line range's `end` field is the last line, INCLUSIVE — same semantics the +// local type carried before this adopted lib-common's shape. + +export interface Decoration { + style: OverlayStyleName; + range: GeneralizedRange; + role: OverlayRole; +} + +export interface Frame { + role: FrameRole; + lines: Line[]; + cursors: Position[]; + selections: Range[]; + decorations: Decoration[]; + /** Spoken form of the command this frame is the BEFORE of (command strip). */ + command?: string; + /** Clipboard contents relevant to this frame (VisualizerMetadata port). */ + clipboard?: string; + /** Seamless-loop reset frame: re-shows the sequence initial state. */ + reset?: boolean; + /** Pre-gif bumper frame: shows the initial state before step 0 begins. */ + pre?: boolean; + /** Explicit duration override (ms). Defaults resolve by role in timeline.ts. */ + durMs?: number; +} + +export interface CascadeMeta { + spokenForm?: string; + action?: string; + fixture?: string; +} + +export interface CascadeState { + theme: Theme; + tabSize: number; + meta?: CascadeMeta; + frames: Frame[]; +} diff --git a/packages/command-visualizer/src/render-command.ts b/packages/command-visualizer/src/render-command.ts new file mode 100644 index 0000000000..49069978ac --- /dev/null +++ b/packages/command-visualizer/src/render-command.ts @@ -0,0 +1,56 @@ +// Top-level orchestrator — the ONE legible, top-to-bottom view of the whole +// pipeline. Read this file to understand what the tool does, in four stages: +// +// 1. get what to render (parse the fixture YAML) +// 2. tokenize each step (documents → Frames: lines, hats, selections) +// 3. generate render object (Frames → CascadeState: flashes, during, overlays) +// 4. render from object (CascadeState → animated SVG string) +// +// This module lives at the package ROOT (alongside index.ts), NOT inside +// logic/ or render/, because it is the ONE allowed composition point that spans +// BOTH scopes. The folder rule (logic/ ⊥ render/) is preserved: neither folder +// imports the other; only this root module and index.ts join them. + +import type { PipelineOptions } from "./logic/pipeline"; +import { + parseFixture, + tokenizeStates, + buildRenderObject, +} from "./logic/pipeline"; +import type { CascadeRenderOptions } from "./render/serialize-cascade"; +import { serializeCascade } from "./render/serialize-cascade"; +import type { SvgWrapOptions } from "./render/svg-wrap"; +import { wrapCascadeSvg } from "./render/svg-wrap"; + +/** Options for the end-to-end orchestrator: pipeline + render + SVG-wrap knobs. */ +export interface RenderCommandOptions + extends PipelineOptions, SvgWrapOptions, CascadeRenderOptions {} + +/** + * Render a single recorded fixture to a standalone animated SVG string. + * + * The four pipeline stages are visible at a glance below; each delegates to an + * existing named function. Output is byte-identical to the equivalent + * fixtureToCascade → serializeCascade → wrapCascadeSvg call chain. + * + * @param src Fixture YAML text. + * @param fixtureRel Relative fixture path (recorded into caption/meta). + * @param opts Pipeline + SVG-wrap options. + */ +export function renderCommand( + src: string, + fixtureRel: string, + opts: RenderCommandOptions = {}, +): string { + // 1. get what to render + const parsed = parseFixture(src, fixtureRel, opts); + // 2. tokenize each step + const tokenized = tokenizeStates(parsed, opts); + // 3. render object + const cascade = buildRenderObject(parsed, tokenized, opts); + const inner = serializeCascade(cascade, { lineNumbers: opts.lineNumbers }); + // 4. render from object + return wrapCascadeSvg(cascade, inner, undefined, { + flashPulseMs: opts.flashPulseMs, + }); +} diff --git a/packages/command-visualizer/src/render/css-cascade-flash.ts b/packages/command-visualizer/src/render/css-cascade-flash.ts new file mode 100644 index 0000000000..a9a564b02a --- /dev/null +++ b/packages/command-visualizer/src/render/css-cascade-flash.ts @@ -0,0 +1,160 @@ +// Cascade flash-fade CSS — DURING beats. Extracted verbatim from css-cascade.ts +// (the flash TIMING section) so the main module stays under the 250-line limit. +// render/ → render/ import only; behavior unchanged. + +import type { OverlayStyleName } from "../data/decorations"; +import { DECORATION_HEX, FLASH_PULSE_MS } from "../data/decorations"; +import type { Timeline } from "../model/timeline"; +import type { Frame } from "../model/types"; +import { pct } from "./css-utils"; + +// DURING beats — flash TIMING. A flash is a TRANSIENT beat *within* one +// frame's timeline slot, not a static band. Two opposite directions: +// +// DELETE (pendingDelete) rides the BEFORE frame (frame 0, slot [0, 1/N]): +// plain (band transparent) → hold plain → red band fades IN +// then the frame's opacity snap crosses to the after-frame (text gone). The band +// fades in LATE in the slot so the before-doc reads plain first, then highlights +// the doomed span just before it vanishes. +// +// ADD (justAdded) rides the AFTER frame (frame N-1, slot [(N-1)/N, 1]): +// green band PRESENT → hold green → green band fades OUT → plain (text stays) +// the insert just happened, so the green is up-front when the after-frame appears, +// then fades out over the latter part of the slot leaving plain text behind. This +// is the mirror image of the delete beat (green out-front, fade to transparent vs. +// red back-end, fade in from transparent). +// +// Both animate ONLY background-color (text stays fully opaque the whole slot); +// CSS-only, zero view-time JS. Each flash style gets its own @keyframes scoped to +// its native frame, so other frames / styles keep the static band. + +const ADD_FLASH_STYLES = ["justAdded"] as const; + +// The flash is a FIXED 100ms pulse, pinned to cursorless's +// `pendingEditDecorationTime` (FLASH_PULSE_MS), DECOUPLED from the readability +// state-hold cadence. +// +// The cascade timeline is `--dur = N · MS_PER_STATE` ms (serialize-cascade.ts), +// so each of the N frame slots lasts MS_PER_STATE ms. Working in keyframe-% of +// the WHOLE timeline (each @keyframes runs over var(--dur)): +// 1% of timeline = (--dur)/100 ms = (N·MS_PER_STATE)/100 ms +// ⇒ a P-ms window in % = P / (N·MS_PER_STATE) · 100 +// The flash's FULL-target held window is exactly FLASH_PULSE_MS ms → `pulsePct` +// below. A short fade ramp (FADE_FRAC of the pulse) softens each edge but the +// held-full-color span is exactly the pulse, which is what verify:flash-timing +// measures. Result: the pulse is ~100ms for ANY N (no slot scaling). +// soft-edge ramp length as a fraction of the pulse window +const FADE_FRAC = 0.4; + +// Reference-class pre-edit flashes (Bring sources/destinations etc.) — they +// sequence BEFORE deletion flashes inside a DURING window (real cursorless +// fires pre-edit flashes in parallel; sequenced here by spec). +const REFERENCE_FLASH_STYLES = [ + "referenced", + "pendingModification0", + "pendingModification1", +] as const; + +const pct100 = (x: number) => pct(Math.max(0, Math.min(100, x * 100))); + +export function flashFadeKeyframes( + frames: readonly Frame[], + tl: Timeline, + pulseMs: number = FLASH_PULSE_MS, +): string { + const out: string[] = []; + + for (let k = 0; k < frames.length; k++) { + const frame = frames[k]; + const lo = tl.startFrac[k]; + const hi = tl.endFrac[k]; + + if (frame.role === "during") { + // DURING window: reference flashes first, deletion flashes second. + // Halves when both classes are present; the full window otherwise. + const styles = new Set(frame.decorations.map((d) => d.style)); + // The during phase keeps its full duration regardless of content, but + // the FIRST flash class present INITIATES AT THE PHASE START — the + // same instant the command pill goes active. When reference flashes + // exist they take the first half and deletion follows at the midpoint; + // with deletions only, red starts with the pill and holds to the edit. + const hasRef = REFERENCE_FLASH_STYLES.some((st) => + styles.has(st as OverlayStyleName), + ); + const mid = (lo + hi) / 2; + const refWin: [number, number] = [lo, mid]; + const delWin: [number, number] = [hasRef ? mid : lo, hi]; + const emit = (style: string, w: [number, number], holdOn: boolean) => { + const ending = holdOn + ? ` 100% { background-color: ${DECORATION_HEX[style as keyof typeof DECORATION_HEX]}; }\n` + : ` ${pct100(w[1] + 0.0001)}% { background-color: transparent; }\n 100% { background-color: transparent; }\n`; + out.push( + `@keyframes flashfade-${style}-s${k} { + 0% { background-color: transparent; } + ${pct100(w[0])}% { background-color: transparent; } + ${pct100(w[0] + 0.0001)}% { background-color: ${DECORATION_HEX[style as keyof typeof DECORATION_HEX]}; } + ${pct100(w[1])}% { background-color: ${DECORATION_HEX[style as keyof typeof DECORATION_HEX]}; } +${ending}}`, + ); + }; + for (const st of REFERENCE_FLASH_STYLES) { + if (styles.has(st)) { + emit(st, refWin, false); + } + } + if (styles.has("pendingDelete")) { + // held to the edit + emit("pendingDelete", delWin, true); + } + } else { + // ADD pulse at the frame's slot start (post-edit justAdded). + const durFrac = pulseMs / tl.totalMs; + const aFullEnd = Math.min(hi, lo + durFrac); + const aFadeEnd = Math.min(hi, aFullEnd + durFrac * FADE_FRAC); + for (const st of ADD_FLASH_STYLES) { + out.push( + `@keyframes flashfade-${st}-s${k} {\n` + + ` 0% { background-color: ${DECORATION_HEX[st]}; }\n` + + ` ${pct100(lo)}% { background-color: ${DECORATION_HEX[st]}; }\n` + + ` ${pct100(aFullEnd)}% { background-color: ${DECORATION_HEX[st]}; }\n` + + ` ${pct100(aFadeEnd)}% { background-color: transparent; }\n` + + ` 100% { background-color: transparent; }\n` + + `}`, + ); + } + } + } + + return out.join("\n"); +} + +export function flashFadeRules(frames: readonly Frame[]): string { + const rules: string[] = []; + for (let k = 0; k < frames.length; k++) { + const frame = frames[k]; + if (frame.role === "during") { + const styles = new Set(frame.decorations.map((d) => d.style)); + for (const st of [...REFERENCE_FLASH_STYLES, "pendingDelete"]) { + if (!styles.has(st as OverlayStyleName)) { + continue; + } + rules.push( + `.cl-cascade .frame[data-frame="${k}"] .ch[data-flash="${st}"] { + background-color: transparent; + animation: flashfade-${st}-s${k} var(--dur, 2s) linear 1 forwards paused; +}`, + ); + } + } else { + for (const st of ADD_FLASH_STYLES) { + rules.push( + `.cl-cascade .frame[data-frame="${k}"] .ch[data-flash="${st}"] { + background-color: ${DECORATION_HEX[st]}; + animation: flashfade-${st}-s${k} var(--dur, 2s) linear 1 forwards paused; +}`, + ); + } + } + } + return rules.join("\n"); +} diff --git a/packages/command-visualizer/src/render/css-cascade.ts b/packages/command-visualizer/src/render/css-cascade.ts new file mode 100644 index 0000000000..55bbc4a61e --- /dev/null +++ b/packages/command-visualizer/src/render/css-cascade.ts @@ -0,0 +1,135 @@ +// Cascade + overlay CSS — highlight bands + stacked-frame opacity timeline. +// Generated from the single-sourced decoration hexes so the +// band colors never drift. Theme-INVARIANT (background-only translucent hexes). + +import { + DECORATION_HEX, + ALL_DECORATION_STYLES, + FLASH_STYLES, + HIGHLIGHT_STYLES, + FLASH_PULSE_MS, +} from "../data/decorations"; +import type { Timeline } from "../model/timeline"; +import { timelineOf } from "../model/timeline"; +import type { Frame } from "../model/types"; +import { flashFadeKeyframes, flashFadeRules } from "./css-cascade-flash"; +import { pct } from "./css-utils"; + +// §3.2 — char-range bands on the per-char grid (one bg attr per .ch). +// The STATIC path (single-frame PNG renders, animate=false): the band is just a +// solid background-color, present for the whole frame. Used by Phase-3 PNG tests. +function charBandRules(): string { + const flash = FLASH_STYLES.map( + (s) => `.ch[data-flash="${s}"] { background-color: ${DECORATION_HEX[s]}; }`, + ); + const hl = HIGHLIGHT_STYLES.map( + (s) => `.ch[data-hl="${s}"] { background-color: ${DECORATION_HEX[s]}; }`, + ); + return [...flash, ...hl].join("\n"); +} + +// The flash TIMING section (DURING beats: delete, insert, reference pre-edit +// flashes) lives in ./css-cascade-flash — imported above. See that module's +// header for the full rationale. + +// Full-width line-range bands on .cl-line (content box). +function lineBandRules(): string { + return ALL_DECORATION_STYLES.map( + (s) => + `.cl-line[data-line-flash="${s}"] { background-color: ${DECORATION_HEX[s]}; display: block; width: 100%; }`, + ).join("\n"); +} + +// Per-frame opacity timeline. Frame k of N is opaque on [k/N,(k+1)/N). +// steps(1,end) gives a hard BEFORE→AFTER snap (no ghosty in-between). +function frameKeyframes(tl: Timeline): string { + const n = tl.startFrac.length; + if (n <= 1) { + return `@keyframes f0 { 0%{opacity:1} 100%{opacity:1} }`; + } + const out: string[] = []; + for (let k = 0; k < n; k++) { + const lo = tl.startFrac[k] * 100; + const hi = tl.endFrac[k] * 100; + if (k === 0) { + out.push( + `@keyframes f0 { 0%{opacity:1} ${pct(hi)}%{opacity:1} ${pct(hi + 0.001)}%{opacity:0} 100%{opacity:0} }`, + ); + } else if (k === n - 1) { + out.push( + `@keyframes f${k} { 0%{opacity:0} ${pct(lo)}%{opacity:0} ${pct(lo + 0.001)}%{opacity:1} 100%{opacity:1} }`, + ); + } else { + out.push( + `@keyframes f${k} { 0%{opacity:0} ${pct(lo)}%{opacity:0} ${pct(lo + 0.001)}%{opacity:1} ${pct(hi)}%{opacity:1} ${pct(hi + 0.001)}%{opacity:0} 100%{opacity:0} }`, + ); + } + } + return out.join("\n"); +} + +export function cascadeStyleSheet( + frames: readonly Frame[], + flashPulseMs: number = FLASH_PULSE_MS, +): string { + const n = Math.max(1, frames.length); + const tl = timelineOf(frames); + // Only multi-frame cascades get the timed DURING beat; n<=1 single-frame + // renders keep the pure-static band (Phase-3 PNG tests must stay green). + const animated = n >= 2; + const flashFade = animated + ? `\n/* ---- DURING beats: delete + insert flash-timing ---- */\n${flashFadeKeyframes(frames, tl, flashPulseMs)}\n${flashFadeRules(frames)}\n` + : ""; + return `/* ---- decoration overlay layer ---- */ +${charBandRules()} +${lineBandRules()} +${flashFade} + +/* ---- cascade container + stacked frames ---- */ +.cl-cascade { + position: relative; + display: inline-block; + min-width: 100%; + box-sizing: border-box; + background: var(--editor-bg, #1e1e1e); + border-radius: 10px; + overflow: hidden; + padding: 1.2em 1em 1.6em; +} +.cl-cascade .frame { + position: absolute; + inset: 1.2em 1em 1.6em; + opacity: 0; + animation: f0 var(--dur, 2s) steps(1, end) 1 forwards paused; +} +/* the FIRST frame establishes the container height (in normal flow) */ +.cl-cascade .frame[data-frame="0"] { position: relative; inset: auto; } + +/* inherit the editor surface chrome onto the cascade box via data-theme */ +.cl-cascade[data-theme] { color: var(--editor-fg, #d4d4d4); } +.cl-cascade .cl-code { + font-family: "JetBrains Mono", "SF Mono", "Menlo", ui-monospace, monospace; + font-size: 18px; + font-variant-ligatures: none; + font-feature-settings: "liga" 0, "calt" 0; + letter-spacing: 0; + line-height: var(--code-line-height, 1.35); + white-space: pre; +} + +/* ---- per-frame opacity timeline ---- */ +${frameKeyframes(tl)} + +/* the static (non-animated) default: show the LAST frame so a no-capture view + still reads as the end state; capture harness seeks each slot. */ +.cl-cascade .frame { animation-play-state: paused; } +.cl-cascade .frame[data-frame="${n - 1}"] { opacity: 1; } +`; +} + +// Map cascade theme vars onto the cascade box (so .cl-cascade gets --editor-bg). +// The base styleSheet() already defines .cl-editor[data-theme]; mirror it here. +export function cascadeThemeBridge(): string { + return `.cl-cascade[data-theme="dark"] { --editor-bg:#1e1e1e; --editor-fg:#d4d4d4; --editor-sel:#264f78; --editor-caret:#aeafad; } +.cl-cascade[data-theme="light"] { --editor-bg:#ffffff; --editor-fg:#1f1f1f; --editor-sel:#add6ff; --editor-caret:#000000; }`; +} diff --git a/packages/command-visualizer/src/render/css-utils.ts b/packages/command-visualizer/src/render/css-utils.ts new file mode 100644 index 0000000000..bd33c7bcef --- /dev/null +++ b/packages/command-visualizer/src/render/css-utils.ts @@ -0,0 +1,2 @@ +// Shared CSS utility functions +export const pct = (x: number): string => x.toFixed(3); diff --git a/packages/command-visualizer/src/render/css.ts b/packages/command-visualizer/src/render/css.ts new file mode 100644 index 0000000000..4bde6914de --- /dev/null +++ b/packages/command-visualizer/src/render/css.ts @@ -0,0 +1,202 @@ +// CSS contract. Generated from the single-sourced data so color + +// adjustment values never drift. No render-time JS; CSS does 100% of visual work. + +import { + DEFAULT_HAT_HEIGHT_EM, + DEFAULT_VERTICAL_OFFSET_EM, + defaultShapeAdjustments as SHAPE_ADJUSTMENTS, + HAT_COLORS, + HAT_SHAPES, +} from "@cursorless/lib-common"; +import { COLOR_MATRIX, EDITOR_CHROME } from "../data/colors"; + +function shapeAdjustmentRules(): string { + const lines: string[] = [ + `.hat { --shape-size-adj: 0; --shape-voffset: 0em; }`, + ]; + for (const shape of HAT_SHAPES) { + const adj = SHAPE_ADJUSTMENTS[shape]; + const parts: string[] = []; + if (adj.sizeAdjustment !== undefined) { + // percent → fraction + parts.push(`--shape-size-adj: ${adj.sizeAdjustment / 100};`); + } + if (adj.verticalOffset !== undefined) { + // percent → em/100 + parts.push(`--shape-voffset: ${adj.verticalOffset / 100}em;`); + } + if (parts.length > 0) { + lines.push( + `.ch--anchor[data-hat-shape="${shape}"] .hat { ${parts.join(" ")} }`, + ); + } + } + return lines.join("\n"); +} + +function colorVarRules(): string { + return HAT_COLORS.map( + (c) => + `.ch--anchor[data-hat-color="${c}"] .hat { --hat-color: var(--c-${c}); }`, + ).join("\n"); +} + +function themeVars(theme: "dark" | "light"): string { + const cm = COLOR_MATRIX[theme]; + const ch = EDITOR_CHROME[theme]; + const colorVars = HAT_COLORS.map((c) => ` --c-${c}: ${cm[c]};`).join("\n"); + return ( + `.cl-editor[data-theme="${theme}"] {\n${colorVars}\n` + + ` --editor-bg: ${ch.bg}; --editor-fg: ${ch.fg};` + + ` --editor-sel: ${ch.sel}; --editor-caret: ${ch.caret};\n}` + ); +} + +export function styleSheet(): string { + return `/* Cursorless state → static CSS. Generated; do not hand-edit. */ + +:root { + --hat-height: ${DEFAULT_HAT_HEIGHT_EM}em; + --hat-base-voffset: ${DEFAULT_VERTICAL_OFFSET_EM}em; + --user-size-adj: 0; /* cursorless.hatSizeAdjustment as fraction */ + --user-voffset: 0em; + /* Real VS Code default line-height resolves to ~1.35× for the editor font + (measured from screenshots/oracle/REAL-main-demo-frame1.png: 24px pitch + over ~17.8px glyphs). Single-sourced here so the hat anchor can stay + glyph-relative regardless of the chosen value. */ + --code-line-height: 1.35; +} + +/* ---- per-shape adjustments (shapeAdjustments.ts) ---- */ +${shapeAdjustmentRules()} + +/* ---- color tint vars ---- */ +${colorVarRules()} + +${themeVars("dark")} +${themeVars("light")} + +/* ---- editor surface ---- */ +@keyframes caretblink { + 0%, 55% { opacity: 1; } + 56%, 100% { opacity: 0; } +} +.caret { animation: caretblink 1.06s steps(1, end) infinite paused; } + +.cl-editor { + background: var(--editor-bg); + color: var(--editor-fg); + border-radius: 10px; + overflow: hidden; + padding: 1.2em 1em 1.6em; + display: inline-block; + min-width: 100%; + box-sizing: border-box; +} +.cl-code { + font-family: "JetBrains Mono", "SF Mono", "Menlo", ui-monospace, monospace; + font-size: 18px; + font-variant-ligatures: none; /* 1 glyph = its column(s) */ + font-feature-settings: "liga" 0, "calt" 0; + letter-spacing: 0; + line-height: var(--code-line-height); + white-space: pre; +} +.cl-line { display: block; min-height: calc(var(--code-line-height) * 1em); } + +/* The shared symbol-sheet (symbols.ts) is a 0×0 def holder, but an inline + still generates a one-line-tall INLINE LINE BOX. As the first child of + .cl-cascade — ahead of the in-flow position:relative frame 0 — that phantom + line box shoved frame 0's content down by 1em (~18px) while the position:absolute + later frames pinned to the padding box and ignored it, so the whole text block + jittered 1em vertically on every frame snap. display:block kills the inline line + box (0-height block contributes nothing), aligning frame 0 with the rest. */ +svg.cl-defs { display: block; } + +/* ---- per-char column grid ---- */ +.ch { + display: inline-block; + position: relative; /* hat's offset parent — no long-line drift */ + width: 1ch; + text-align: center; +} +.ch[data-col-span="2"] { width: 2ch; } +.ch[data-col-span="3"] { width: 3ch; } +.ch[data-col-span="4"] { width: 4ch; } +.ch[data-col-span="5"] { width: 5ch; } +.ch[data-col-span="6"] { width: 6ch; } +.ch[data-col-span="7"] { width: 7ch; } +.ch[data-col-span="8"] { width: 8ch; } + +/* ---- hat: tint via color + currentColor ---- */ +.hat { + position: absolute; + left: 50%; + height: calc(var(--hat-height) * (1 + var(--user-size-adj) + var(--shape-size-adj))); + width: calc(var(--hat-height) * (1 + var(--user-size-adj) + var(--shape-size-adj)) * 12 / 9); + transform: translateX(-50%); + /* Anchor the hat to the GLYPH TOP, not the line-box bottom, so it hugs the + character cap regardless of line-height (cursorless positions the hat from + the glyph: VscodeHatRenderer.ts:67, hatVOffsetPx = vOffsetEm*fontSize, + glyph-relative). .ch is inline-block so its box height == line-height; + the glyph content box (1em) is centered, leaving (line-height - 1em)/2 of + half-leading above it. Add that half-leading back to the 1em baseline-stack + so the hat sits just above the cap and never drifts into the inter-line gap + when line-height changes. + + The final term mirrors cursorless's + hatVerticalOffsetPx = (0.05 + voffsetEm)*fontSize - hatHeightPx/2 + (VscodeHatRenderer.ts:222-241). Cursorless lifts the hat bottom by half the + RENDERED hat height, so taller shapes sink lower relative to the glyph top. + Our eye-tuned baseline above already lands the DEFAULT shape correctly + (which carries shapeSizeAdj -0.30), so we only need the per-shape DELTA from + that default height -- subtracting (thisHatHeight - defaultShapeHatHeight)/2 + reproduces cursorless's -hatHeightPx/2 spread without disturbing the tuned + default. thisHatHeight reuses the exact height calc; defaultShapeHatHeight + is the same expression with shapeSizeAdj pinned to the default-shape -0.30. */ + bottom: calc(1em + (var(--code-line-height) - 1) * 0.5em + + var(--hat-base-voffset) + var(--user-voffset) + var(--shape-voffset) + - (var(--hat-height) * (1 + var(--user-size-adj) + var(--shape-size-adj)) + - var(--hat-height) * (1 + var(--user-size-adj) - 0.30)) / 2); + color: var(--hat-color); + pointer-events: none; + overflow: visible; + display: block; +} + +/* ---- cursor + selection ---- */ +.caret[data-cursor] { + display: inline-block; + width: 0; height: 1.2em; + margin: 0 -1px; + border-left: 2px solid var(--editor-caret); + vertical-align: text-bottom; +} +.ch[data-sel] { background: var(--editor-sel); } + +/* ---- optional line-number gutter: OFF by default ---- + A leading inline-block .cl-lineno per .cl-line. Because the number shares the + line box with its code, it aligns to that exact row automatically — works + identically for single-frame renders and absolutely-positioned stacked cascade + frames (no offset math). The gutter only exists when an ancestor carries + data-line-numbers; absent that attribute, no .cl-lineno is emitted and output + is byte-identical to the no-gutter render. */ +:root { + --gutter-digits: 2; /* widest line number's digit count */ + --gutter-pad-right: 1.1ch; /* gap between numbers and code */ + --gutter-pad-left: 0.6ch; +} +.cl-lineno { + display: inline-block; + width: calc(var(--gutter-digits) * 1ch); + padding-left: var(--gutter-pad-left); + padding-right: var(--gutter-pad-right); + text-align: right; /* VS Code: right-aligned numbers */ + color: var(--editor-fg); + opacity: 0.42; /* dim, muted gray */ + font-variant-numeric: tabular-nums; + user-select: none; + pointer-events: none; +} +`; +} diff --git a/packages/command-visualizer/src/render/html.test.ts b/packages/command-visualizer/src/render/html.test.ts new file mode 100644 index 0000000000..87a5be4732 --- /dev/null +++ b/packages/command-visualizer/src/render/html.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { captionHtml, esc, themeBackground } from "./html"; + +suite("command-visualizer/html", () => { + suite("esc", () => { + test("escapes all five HTML-significant characters", () => { + assert.equal(esc("&"), "&"); + assert.equal(esc("<"), "<"); + assert.equal(esc(">"), ">"); + assert.equal(esc('"'), """); + assert.equal(esc("'"), "'"); + }); + + test("escapes & first so entities are not double-escaped", () => { + // If & were escaped after <, "<" -> "<" -> "&lt;" (wrong). + assert.equal(esc("<&>"), "<&>"); + }); + + test("neutralizes an attribute-breakout payload (CodeQL regression)", () => { + const payload = 'x" onmouseover="alert(1)'; + const escaped = esc(payload); + assert.ok(!escaped.includes('"'), "no raw double-quote survives"); + assert.equal(escaped, "x" onmouseover="alert(1)"); + }); + + test("leaves ordinary text untouched", () => { + assert.equal(esc("changeToken hello 42"), "changeToken hello 42"); + }); + }); + + suite("themeBackground", () => { + test("maps dark and light to their page backgrounds", () => { + assert.equal(themeBackground("dark"), "#141414"); + assert.equal(themeBackground("light"), "#e8e8e8"); + }); + }); + + suite("captionHtml", () => { + test("returns empty string when there is no meta", () => { + assert.equal(captionHtml(undefined), ""); + }); + + test("joins spoken form, action and fixture with a middot separator", () => { + const html = captionHtml({ + spokenForm: "change token", + action: "clearAndSetSelection", + fixture: "recorded/changeToken.yml", + }); + assert.equal( + html, + '
"change token" · ' + + "clearAndSetSelection · recorded/changeToken.yml
", + ); + }); + + test("omits absent fields rather than emitting empty segments", () => { + assert.equal( + captionHtml({ action: "remove" }), + '
remove
', + ); + }); + + test("escapes meta content so it cannot inject markup", () => { + const html = captionHtml({ fixture: "" }); + assert.ok(!html.includes("