diff --git a/docs/widgets/index.md b/docs/widgets/index.md index a59cb2cc..c7ff2293 100644 --- a/docs/widgets/index.md +++ b/docs/widgets/index.md @@ -11,118 +11,128 @@ app.view((state) => ); ``` +## Stability + +Widget stability tiers and guarantees are documented in [Widget Stability](stability.md). + +Tier labels used in this catalog: + +- `stable`: semver-protected behavior contract with deterministic regression tests. +- `beta`: core invariants are tested; contract may evolve. +- `experimental`: no compatibility guarantees. + ## Widget Categories ### Primitives Foundation widgets for layout and content: -| Widget | Description | Focusable | -|--------|-------------|-----------| -| [Text](text.md) | Display text with optional styling | No | -| [Box](box.md) | Container with border, padding, and title | No | -| [Row / Column](stack.md) | Horizontal and vertical stack layouts | No | -| [Spacer](spacer.md) | Fixed-size or flexible spacing | No | -| [Divider](divider.md) | Visual separator line | No | +| Widget | Description | Focusable | Stability | +|--------|-------------|-----------|-----------| +| [Text](text.md) | Display text with optional styling | No | `stable` | +| [Box](box.md) | Container with border, padding, and title | No | `stable` | +| [Row / Column](stack.md) | Horizontal and vertical stack layouts | No | `stable` | +| [Spacer](spacer.md) | Fixed-size or flexible spacing | No | `stable` | +| [Divider](divider.md) | Visual separator line | No | `stable` | ### Indicators Visual feedback and status display: -| Widget | Description | Focusable | -|--------|-------------|-----------| -| [Icon](icon.md) | Single-character icon from registry | No | -| [Spinner](spinner.md) | Animated loading indicator | No | -| [Progress](progress.md) | Progress bar with variants | No | -| [Skeleton](skeleton.md) | Loading placeholder | No | -| [RichText](rich-text.md) | Multi-styled text spans | No | -| [Kbd](kbd.md) | Keyboard shortcut display | No | -| [Badge](badge.md) | Small status indicator | No | -| [Status](status.md) | Online/offline status dot | No | -| [Tag](tag.md) | Inline label with background | No | +| Widget | Description | Focusable | Stability | +|--------|-------------|-----------|-----------| +| [Icon](icon.md) | Single-character icon from registry | No | `beta` | +| [Spinner](spinner.md) | Animated loading indicator | No | `beta` | +| [Progress](progress.md) | Progress bar with variants | No | `beta` | +| [Skeleton](skeleton.md) | Loading placeholder | No | `beta` | +| [RichText](rich-text.md) | Multi-styled text spans | No | `beta` | +| [Kbd](kbd.md) | Keyboard shortcut display | No | `beta` | +| [Badge](badge.md) | Small status indicator | No | `beta` | +| [Status](status.md) | Online/offline status dot | No | `beta` | +| [Tag](tag.md) | Inline label with background | No | `beta` | ### Form Inputs Interactive form controls: -| Widget | Description | Focusable | -|--------|-------------|-----------| -| [Button](button.md) | Clickable button with label | Yes | -| [Input](input.md) | Single-line text input | Yes | -| [Slider](slider.md) | Numeric range input | Yes | -| [Checkbox](checkbox.md) | Toggle checkbox | Yes | -| [Radio Group](radio-group.md) | Single-select options | Yes | -| [Select](select.md) | Dropdown selection | Yes | -| [Field](field.md) | Form field wrapper with label/error | No | +| Widget | Description | Focusable | Stability | +|--------|-------------|-----------|-----------| +| [Button](button.md) | Clickable button with label | Yes | `beta` | +| [Input](input.md) | Single-line text input | Yes | `stable` | +| [Slider](slider.md) | Numeric range input | Yes | `beta` | +| [Checkbox](checkbox.md) | Toggle checkbox | Yes | `beta` | +| [Radio Group](radio-group.md) | Single-select options | Yes | `beta` | +| [Select](select.md) | Dropdown selection | Yes | `beta` | +| [Field](field.md) | Form field wrapper with label/error | No | `beta` | ### Data Display Tables, lists, and trees: -| Widget | Description | Focusable | -|--------|-------------|-----------| -| [Table](table.md) | Tabular data with sorting and selection | Yes | -| [Virtual List](virtual-list.md) | Efficiently render large lists | Yes | -| [Tree](tree.md) | Hierarchical data with expand/collapse | Yes | +| Widget | Description | Focusable | Stability | +|--------|-------------|-----------|-----------| +| [Table](table.md) | Tabular data with sorting and selection | Yes | `stable` | +| [Virtual List](virtual-list.md) | Efficiently render large lists | Yes | `stable` | +| [Tree](tree.md) | Hierarchical data with expand/collapse | Yes | `beta` | ### Overlays Modal and popup interfaces: -| Widget | Description | Focusable | -|--------|-------------|-----------| -| [Layers](layers.md) | Layer stack container | No | -| [Modal](modal.md) | Centered modal dialog | Yes | -| [Dropdown](dropdown.md) | Positioned dropdown menu | Yes | -| [Layer](layer.md) | Generic overlay layer | Varies | -| [Toast](toast.md) | Non-blocking notifications | No | -| [Focus Zone](focus-zone.md) | Focus group for Tab navigation | No | -| [Focus Trap](focus-trap.md) | Constrain focus to region | No | +| Widget | Description | Focusable | Stability | +|--------|-------------|-----------|-----------| +| [Layers](layers.md) | Layer stack container | No | `beta` | +| [Modal](modal.md) | Centered modal dialog | Yes | `beta` | +| [Dropdown](dropdown.md) | Positioned dropdown menu | Yes | `beta` | +| [Layer](layer.md) | Generic overlay layer | Varies | `beta` | +| [Toast](toast.md) | Non-blocking notifications | No | `beta` | +| [Focus Zone](focus-zone.md) | Focus group for Tab navigation | No | `beta` | +| [Focus Trap](focus-trap.md) | Constrain focus to region | No | `beta` | ### Layout Complex layout components: -| Widget | Description | Focusable | -|--------|-------------|-----------| -| [Split Pane](split-pane.md) | Resizable split layout | Yes | -| [Panel Group](panel-group.md) | Container for resizable panels | No | -| [Resizable Panel](resizable-panel.md) | Panel within group | No | +| Widget | Description | Focusable | Stability | +|--------|-------------|-----------|-----------| +| [Split Pane](split-pane.md) | Resizable split layout | Yes | `beta` | +| [Panel Group](panel-group.md) | Container for resizable panels | No | `beta` | +| [Resizable Panel](resizable-panel.md) | Panel within group | No | `beta` | ### Advanced Rich, specialized widgets: -| Widget | Description | Focusable | -|--------|-------------|-----------| -| [Command Palette](command-palette.md) | Quick command search | Yes | -| [File Picker](file-picker.md) | File browser with selection | Yes | -| [File Tree Explorer](file-tree-explorer.md) | File system tree view | Yes | -| [Code Editor](code-editor.md) | Multi-line code editing | Yes | -| [Diff Viewer](diff-viewer.md) | Unified/side-by-side diff | Yes | -| [Logs Console](logs-console.md) | Streaming log output | Yes | -| [Tool Approval Dialog](tool-approval-dialog.md) | Tool execution review | Yes | +| Widget | Description | Focusable | Stability | +|--------|-------------|-----------|-----------| +| [Command Palette](command-palette.md) | Quick command search | Yes | `stable` | +| [File Picker](file-picker.md) | File browser with selection | Yes | `stable` | +| [File Tree Explorer](file-tree-explorer.md) | File system tree view | Yes | `stable` | +| [Code Editor](code-editor.md) | Multi-line code editing | Yes | `beta` | +| [Diff Viewer](diff-viewer.md) | Unified/side-by-side diff | Yes | `beta` | +| [Logs Console](logs-console.md) | Streaming log output | Yes | `beta` | +| [Tool Approval Dialog](tool-approval-dialog.md) | Tool execution review | Yes | `experimental` | ### Charts Data visualization: -| Widget | Description | Focusable | -|--------|-------------|-----------| -| [Gauge](gauge.md) | Compact progress with label | No | -| [Sparkline](sparkline.md) | Inline mini chart | No | -| [Bar Chart](bar-chart.md) | Horizontal/vertical bars | No | -| [Mini Chart](mini-chart.md) | Compact multi-value display | No | +| Widget | Description | Focusable | Stability | +|--------|-------------|-----------|-----------| +| [Gauge](gauge.md) | Compact progress with label | No | `beta` | +| [Sparkline](sparkline.md) | Inline mini chart | No | `beta` | +| [Bar Chart](bar-chart.md) | Horizontal/vertical bars | No | `beta` | +| [Mini Chart](mini-chart.md) | Compact multi-value display | No | `beta` | ### Feedback User feedback and states: -| Widget | Description | Focusable | -|--------|-------------|-----------| -| [Callout](callout.md) | Alert/info message box | No | -| [Error Display](error-display.md) | Error message with retry | Yes | -| [Empty](empty.md) | Empty state placeholder | No | +| Widget | Description | Focusable | Stability | +|--------|-------------|-----------|-----------| +| [Callout](callout.md) | Alert/info message box | No | `beta` | +| [Error Display](error-display.md) | Error message with retry | Yes | `beta` | +| [Empty](empty.md) | Empty state placeholder | No | `beta` | ## Common Patterns diff --git a/docs/widgets/stability.md b/docs/widgets/stability.md new file mode 100644 index 00000000..bb090eed --- /dev/null +++ b/docs/widgets/stability.md @@ -0,0 +1,86 @@ +# Widget Stability + +Rezi uses stability tiers so teams can choose widgets with clear behavior guarantees. + +## Tiers + +- `stable`: behavior contract and deterministic tests exist; semver guarantees apply to the documented stable surface. +- `beta`: usable and tested for core invariants, but parts of the contract can still evolve. +- `experimental`: no compatibility guarantees; behavior and APIs can change quickly. + +## Stable Guarantees + +When a widget is marked `stable`, Rezi guarantees: + +- deterministic behavior for documented keyboard, pointer, and editing contracts +- deterministic regression tests that pin those contracts in `packages/core/src/**/__tests__` +- no breaking changes to documented stable behavior in minor or patch releases +- any required stable-surface behavior change is treated as semver-major + +## Daily Driver Status + +These widgets are the EPIC-04 hardening targets and are currently `stable`. + +| Widget | Tier | Contract coverage | +|--------|------|-------------------| +| [Input](input.md) | `stable` | Cursor/edit/paste/focus-capture contract tests in `packages/core/src/runtime/__tests__/inputEditor.contract.test.ts` | +| [Table](table.md) | `stable` | Selection/column-width/viewport/row-key tests in `packages/core/src/widgets/__tests__/table.golden.test.ts` and `packages/core/src/app/__tests__/table.renderCache.test.ts` | +| [Virtual List](virtual-list.md) | `stable` | Visible-range/overscan/scroll-clamp/navigation tests in `packages/core/src/widgets/__tests__/virtualList.contract.test.ts` | +| [Command Palette](command-palette.md) | `stable` | Async fetch ordering/stale-cancel/query/nav/escape tests in `packages/core/src/app/__tests__/commandPaletteRouting.test.ts` and `packages/core/src/widgets/__tests__/commandPalette.test.ts` | +| [File Picker](file-picker.md) | `stable` | Expand/collapse/selection/open/toggle contracts in `packages/core/src/app/__tests__/filePickerRouting.contracts.test.ts` | +| [File Tree Explorer](file-tree-explorer.md) | `stable` | Focus/activation/toggle/context-menu contracts in `packages/core/src/app/__tests__/fileTreeExplorer.contextMenu.test.ts` | + +## Full Catalog Status + +The full catalog is tiered below. `stable` is intentionally conservative and reserved for widgets with hardened behavior contracts. + +| Category | Widget | Tier | +|----------|--------|------| +| Primitives | [Text](text.md) | `stable` | +| Primitives | [Box](box.md) | `stable` | +| Primitives | [Row / Column](stack.md) | `stable` | +| Primitives | [Spacer](spacer.md) | `stable` | +| Primitives | [Divider](divider.md) | `stable` | +| Indicators | [Icon](icon.md) | `beta` | +| Indicators | [Spinner](spinner.md) | `beta` | +| Indicators | [Progress](progress.md) | `beta` | +| Indicators | [Skeleton](skeleton.md) | `beta` | +| Indicators | [RichText](rich-text.md) | `beta` | +| Indicators | [Kbd](kbd.md) | `beta` | +| Indicators | [Badge](badge.md) | `beta` | +| Indicators | [Status](status.md) | `beta` | +| Indicators | [Tag](tag.md) | `beta` | +| Form Inputs | [Button](button.md) | `beta` | +| Form Inputs | [Input](input.md) | `stable` | +| Form Inputs | [Slider](slider.md) | `beta` | +| Form Inputs | [Checkbox](checkbox.md) | `beta` | +| Form Inputs | [Radio Group](radio-group.md) | `beta` | +| Form Inputs | [Select](select.md) | `beta` | +| Form Inputs | [Field](field.md) | `beta` | +| Data Display | [Table](table.md) | `stable` | +| Data Display | [Virtual List](virtual-list.md) | `stable` | +| Data Display | [Tree](tree.md) | `beta` | +| Overlays | [Layers](layers.md) | `beta` | +| Overlays | [Modal](modal.md) | `beta` | +| Overlays | [Dropdown](dropdown.md) | `beta` | +| Overlays | [Layer](layer.md) | `beta` | +| Overlays | [Toast](toast.md) | `beta` | +| Overlays | [Focus Zone](focus-zone.md) | `beta` | +| Overlays | [Focus Trap](focus-trap.md) | `beta` | +| Layout | [Split Pane](split-pane.md) | `beta` | +| Layout | [Panel Group](panel-group.md) | `beta` | +| Layout | [Resizable Panel](resizable-panel.md) | `beta` | +| Advanced | [Command Palette](command-palette.md) | `stable` | +| Advanced | [File Picker](file-picker.md) | `stable` | +| Advanced | [File Tree Explorer](file-tree-explorer.md) | `stable` | +| Advanced | [Code Editor](code-editor.md) | `beta` | +| Advanced | [Diff Viewer](diff-viewer.md) | `beta` | +| Advanced | [Logs Console](logs-console.md) | `beta` | +| Advanced | [Tool Approval Dialog](tool-approval-dialog.md) | `experimental` | +| Charts | [Gauge](gauge.md) | `beta` | +| Charts | [Sparkline](sparkline.md) | `beta` | +| Charts | [Bar Chart](bar-chart.md) | `beta` | +| Charts | [Mini Chart](mini-chart.md) | `beta` | +| Feedback | [Callout](callout.md) | `beta` | +| Feedback | [Error Display](error-display.md) | `beta` | +| Feedback | [Empty](empty.md) | `beta` | diff --git a/packages/core/src/app/__tests__/commandPaletteRouting.test.ts b/packages/core/src/app/__tests__/commandPaletteRouting.test.ts new file mode 100644 index 00000000..0ebe6b7b --- /dev/null +++ b/packages/core/src/app/__tests__/commandPaletteRouting.test.ts @@ -0,0 +1,397 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { RuntimeBackend } from "../../backend.js"; +import type { ZrevEvent } from "../../events.js"; +import { ui } from "../../index.js"; +import { + ZR_KEY_BACKSPACE, + ZR_KEY_DOWN, + ZR_KEY_ENTER, + ZR_KEY_ESCAPE, + ZR_KEY_TAB, + ZR_KEY_UP, +} from "../../keybindings/keyCodes.js"; +import { DEFAULT_TERMINAL_CAPS } from "../../terminalCaps.js"; +import { defaultTheme } from "../../theme/defaultTheme.js"; +import type { CommandItem, CommandPaletteProps, CommandSource } from "../../widgets/types.js"; +import { WidgetRenderer } from "../widgetRenderer.js"; +import { + kickoffCommandPaletteItemFetches, + routeCommandPaletteKeyDown, +} from "../widgetRenderer/commandPaletteRouting.js"; +import { flushMicrotasks } from "./helpers.js"; + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve: ((value: T) => void) | undefined; + const promise = new Promise((res) => { + resolve = res; + }); + return { + promise, + resolve: (value: T) => resolve?.(value), + }; +} + +function keyEvent(key: number): ZrevEvent { + return { kind: "key", timeMs: 0, key, mods: 0, action: "down" }; +} + +function noRenderHooks(): { enterRender: () => void; exitRender: () => void } { + return { enterRender: () => {}, exitRender: () => {} }; +} + +function createNoopBackend(): RuntimeBackend { + return { + start: async () => {}, + stop: async () => {}, + dispose: () => {}, + requestFrame: async () => {}, + pollEvents: async () => + new Promise((_) => { + // Not used by WidgetRenderer unit-style tests. + }), + postUserEvent: () => {}, + getCaps: async () => DEFAULT_TERMINAL_CAPS, + }; +} + +describe("commandPalette routing contracts", () => { + test("selection movement skips disabled items and wraps", () => { + const items: readonly CommandItem[] = Object.freeze([ + { id: "disabled", label: "Disabled", sourceId: "commands", disabled: true }, + { id: "enabled-1", label: "Enabled One", sourceId: "commands" }, + { id: "enabled-2", label: "Enabled Two", sourceId: "commands" }, + ]); + const selectionChanges: number[] = []; + + const basePalette: CommandPaletteProps = { + id: "cp", + open: true, + query: "", + sources: Object.freeze([]), + selectedIndex: 0, + onQueryChange: () => {}, + onSelect: () => {}, + onClose: () => {}, + onSelectionChange: (index) => selectionChanges.push(index), + }; + + assert.equal(routeCommandPaletteKeyDown(keyEvent(ZR_KEY_DOWN), basePalette, items), true); + + const upPalette: CommandPaletteProps = { ...basePalette, selectedIndex: 1 }; + assert.equal(routeCommandPaletteKeyDown(keyEvent(ZR_KEY_UP), upPalette, items), true); + + assert.deepEqual(selectionChanges, [1, 2]); + }); + + test("enter activates selected item or falls back to first enabled item", () => { + const items: readonly CommandItem[] = Object.freeze([ + { id: "disabled", label: "Disabled", sourceId: "commands", disabled: true }, + { id: "enabled-1", label: "Enabled One", sourceId: "commands" }, + { id: "enabled-2", label: "Enabled Two", sourceId: "commands" }, + ]); + const activated: string[] = []; + let closedCount = 0; + + const palette: CommandPaletteProps = { + id: "cp", + open: true, + query: "", + sources: Object.freeze([]), + selectedIndex: 0, + onQueryChange: () => {}, + onSelect: (item) => activated.push(item.id), + onClose: () => { + closedCount++; + }, + }; + + assert.equal(routeCommandPaletteKeyDown(keyEvent(ZR_KEY_ENTER), palette, items), true); + assert.deepEqual(activated, ["enabled-1"]); + assert.equal(closedCount, 1); + }); + + test("backspace updates query immediately and resets selection", () => { + const queryChanges: string[] = []; + const selectionChanges: number[] = []; + + const palette: CommandPaletteProps = { + id: "cp", + open: true, + query: "abc", + sources: Object.freeze([]), + selectedIndex: 3, + onQueryChange: (next) => queryChanges.push(next), + onSelect: () => {}, + onClose: () => {}, + onSelectionChange: (index) => selectionChanges.push(index), + }; + + assert.equal(routeCommandPaletteKeyDown(keyEvent(ZR_KEY_BACKSPACE), palette, []), true); + assert.deepEqual(queryChanges, ["ab"]); + assert.deepEqual(selectionChanges, [0]); + }); +}); + +describe("commandPalette async fetch contracts", () => { + test("query/sources identity gate refetches and query changes trigger immediate fetch", () => { + const first = createDeferred(); + const second = createDeferred(); + const requestedQueries: string[] = []; + + const source: CommandSource = { + id: "commands", + name: "Commands", + getItems: (query) => { + requestedQueries.push(query); + if (query === "a") return first.promise; + if (query === "ab") return second.promise; + return Object.freeze([]); + }, + }; + const sources: readonly CommandSource[] = Object.freeze([source]); + + const commandPaletteById = new Map(); + const commandPaletteItemsById = new Map(); + const commandPaletteLoadingById = new Map(); + const commandPaletteFetchTokenById = new Map(); + const commandPaletteLastQueryById = new Map(); + const commandPaletteLastSourcesRefById = new Map(); + let renderCount = 0; + + const paletteForQuery = (query: string): CommandPaletteProps => ({ + id: "cp", + open: true, + query, + sources, + selectedIndex: 0, + onQueryChange: () => {}, + onSelect: () => {}, + onClose: () => {}, + }); + + commandPaletteById.set("cp", paletteForQuery("a")); + kickoffCommandPaletteItemFetches( + commandPaletteById, + commandPaletteItemsById, + commandPaletteLoadingById, + commandPaletteFetchTokenById, + commandPaletteLastQueryById, + commandPaletteLastSourcesRefById, + () => { + renderCount++; + }, + ); + + // Same query and same sources reference should not schedule another fetch. + kickoffCommandPaletteItemFetches( + commandPaletteById, + commandPaletteItemsById, + commandPaletteLoadingById, + commandPaletteFetchTokenById, + commandPaletteLastQueryById, + commandPaletteLastSourcesRefById, + () => { + renderCount++; + }, + ); + + commandPaletteById.set("cp", paletteForQuery("ab")); + kickoffCommandPaletteItemFetches( + commandPaletteById, + commandPaletteItemsById, + commandPaletteLoadingById, + commandPaletteFetchTokenById, + commandPaletteLastQueryById, + commandPaletteLastSourcesRefById, + () => { + renderCount++; + }, + ); + + assert.deepEqual(requestedQueries, ["a", "ab"]); + assert.equal(commandPaletteLoadingById.get("cp"), true); + assert.equal(renderCount, 0); + }); + + test("stale async results are ignored when a newer query fetch starts", async () => { + const first = createDeferred(); + const second = createDeferred(); + + const source: CommandSource = { + id: "commands", + name: "Commands", + getItems: (query) => { + if (query === "a") return first.promise; + if (query === "ab") return second.promise; + return Object.freeze([]); + }, + }; + const sources: readonly CommandSource[] = Object.freeze([source]); + + const commandPaletteById = new Map(); + const commandPaletteItemsById = new Map(); + const commandPaletteLoadingById = new Map(); + const commandPaletteFetchTokenById = new Map(); + const commandPaletteLastQueryById = new Map(); + const commandPaletteLastSourcesRefById = new Map(); + let renderCount = 0; + + const paletteForQuery = (query: string): CommandPaletteProps => ({ + id: "cp", + open: true, + query, + sources, + selectedIndex: 0, + onQueryChange: () => {}, + onSelect: () => {}, + onClose: () => {}, + }); + + commandPaletteById.set("cp", paletteForQuery("a")); + kickoffCommandPaletteItemFetches( + commandPaletteById, + commandPaletteItemsById, + commandPaletteLoadingById, + commandPaletteFetchTokenById, + commandPaletteLastQueryById, + commandPaletteLastSourcesRefById, + () => { + renderCount++; + }, + ); + + commandPaletteById.set("cp", paletteForQuery("ab")); + kickoffCommandPaletteItemFetches( + commandPaletteById, + commandPaletteItemsById, + commandPaletteLoadingById, + commandPaletteFetchTokenById, + commandPaletteLastQueryById, + commandPaletteLastSourcesRefById, + () => { + renderCount++; + }, + ); + + second.resolve( + Object.freeze([ + { id: "new", label: "ab candidate", sourceId: "commands" }, + ] satisfies CommandItem[]), + ); + await flushMicrotasks(4); + + assert.deepEqual( + commandPaletteItemsById.get("cp")?.map((item) => item.id), + ["new"], + ); + assert.equal(commandPaletteLoadingById.get("cp"), false); + assert.equal(renderCount, 1); + + first.resolve( + Object.freeze([ + { id: "old", label: "a candidate", sourceId: "commands" }, + ] satisfies CommandItem[]), + ); + await flushMicrotasks(4); + + assert.deepEqual( + commandPaletteItemsById.get("cp")?.map((item) => item.id), + ["new"], + ); + assert.equal(commandPaletteLoadingById.get("cp"), false); + assert.equal(renderCount, 1); + }); +}); + +describe("commandPalette escape contracts in layered focus contexts", () => { + test("modal layer with closeOnEscape=false routes Escape to focused command palette", () => { + const backend = createNoopBackend(); + const renderer = new WidgetRenderer({ + backend, + requestRender: () => {}, + }); + const events: string[] = []; + + const vnode = ui.layers([ + ui.layer({ + id: "modal", + modal: true, + closeOnEscape: false, + onClose: () => events.push("layer-close"), + content: ui.commandPalette({ + id: "cp", + open: true, + query: "", + sources: Object.freeze([]), + selectedIndex: 0, + onQueryChange: () => {}, + onSelect: () => {}, + onClose: () => events.push("palette-close"), + }), + }), + ]); + + const res = renderer.submitFrame( + () => vnode, + undefined, + { cols: 60, rows: 10 }, + defaultTheme, + noRenderHooks(), + ); + assert.ok(res.ok); + assert.equal(renderer.getFocusedId(), null); + + renderer.routeEngineEvent(keyEvent(ZR_KEY_TAB)); + assert.equal(renderer.getFocusedId(), "cp"); + + renderer.routeEngineEvent(keyEvent(ZR_KEY_ESCAPE)); + assert.deepEqual(events, ["palette-close"]); + }); + + test("modal layer with closeOnEscape=true closes layer before palette handler", () => { + const backend = createNoopBackend(); + const renderer = new WidgetRenderer({ + backend, + requestRender: () => {}, + }); + const events: string[] = []; + + const vnode = ui.layers([ + ui.layer({ + id: "modal", + modal: true, + closeOnEscape: true, + onClose: () => events.push("layer-close"), + content: ui.commandPalette({ + id: "cp", + open: true, + query: "", + sources: Object.freeze([]), + selectedIndex: 0, + onQueryChange: () => {}, + onSelect: () => {}, + onClose: () => events.push("palette-close"), + }), + }), + ]); + + const res = renderer.submitFrame( + () => vnode, + undefined, + { cols: 60, rows: 10 }, + defaultTheme, + noRenderHooks(), + ); + assert.ok(res.ok); + assert.equal(renderer.getFocusedId(), null); + + renderer.routeEngineEvent(keyEvent(ZR_KEY_TAB)); + assert.equal(renderer.getFocusedId(), "cp"); + + renderer.routeEngineEvent(keyEvent(ZR_KEY_ESCAPE)); + assert.deepEqual(events, ["layer-close"]); + }); +}); diff --git a/packages/core/src/app/__tests__/filePickerRouting.contracts.test.ts b/packages/core/src/app/__tests__/filePickerRouting.contracts.test.ts new file mode 100644 index 00000000..3ab2bc6c --- /dev/null +++ b/packages/core/src/app/__tests__/filePickerRouting.contracts.test.ts @@ -0,0 +1,257 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { ZrevEvent } from "../../events.js"; +import { + ZR_KEY_DOWN, + ZR_KEY_ENTER, + ZR_KEY_LEFT, + ZR_KEY_RIGHT, +} from "../../keybindings/keyCodes.js"; +import { createTreeStateStore } from "../../runtime/localState.js"; +import type { FileNode, FilePickerProps, FileTreeExplorerProps } from "../../widgets/types.js"; +import { + routeFilePickerKeyDown, + routeFileTreeExplorerKeyDown, +} from "../widgetRenderer/filePickerRouting.js"; + +function keyDown(key: number): ZrevEvent { + return { kind: "key", timeMs: 0, key, mods: 0, action: "down" }; +} + +function createFileTreeData(): FileNode { + return Object.freeze({ + name: "root", + path: "/", + type: "directory" as const, + children: Object.freeze([ + Object.freeze({ name: "a.txt", path: "/a.txt", type: "file" as const }), + Object.freeze({ + name: "dir", + path: "/dir", + type: "directory" as const, + children: Object.freeze([ + Object.freeze({ name: "inner.txt", path: "/dir/inner.txt", type: "file" as const }), + ]), + }), + Object.freeze({ name: "b.txt", path: "/b.txt", type: "file" as const }), + ]), + }); +} + +describe("file picker routing contracts", () => { + test("uses selectedPath as initial keyboard focus for Enter and ArrowDown", () => { + const treeStore = createTreeStateStore(); + const selected: string[] = []; + const toggled: string[] = []; + const opened: string[] = []; + + const props: FilePickerProps = { + id: "fp-fallback", + rootPath: "/", + data: createFileTreeData(), + expandedPaths: Object.freeze(["/"]), + selectedPath: "/a.txt", + onSelect: (path) => selected.push(path), + onToggle: (path, next) => toggled.push(`${path}:${String(next)}`), + onOpen: (path) => opened.push(path), + }; + + assert.equal(routeFilePickerKeyDown(keyDown(ZR_KEY_ENTER), props, treeStore), true); + assert.deepEqual(opened, ["/a.txt"]); + assert.deepEqual(selected, []); + assert.deepEqual(toggled, []); + + assert.equal(routeFilePickerKeyDown(keyDown(ZR_KEY_DOWN), props, treeStore), true); + assert.deepEqual(selected, ["/dir"]); + assert.equal(treeStore.get(props.id).focusedKey, "/dir"); + }); + + test("expand/collapse + activate callbacks are deterministic", () => { + const treeStore = createTreeStateStore(); + const selected: string[] = []; + const toggled: string[] = []; + const opened: string[] = []; + + const collapsedProps: FilePickerProps = { + id: "fp-callbacks", + rootPath: "/", + data: createFileTreeData(), + expandedPaths: Object.freeze(["/"]), + selectedPath: "/dir", + onSelect: (path) => selected.push(path), + onToggle: (path, next) => toggled.push(`${path}:${String(next)}`), + onOpen: (path) => opened.push(path), + }; + + assert.equal(routeFilePickerKeyDown(keyDown(ZR_KEY_RIGHT), collapsedProps, treeStore), true); + assert.deepEqual(toggled, ["/dir:true"]); + assert.deepEqual(opened, []); + assert.deepEqual(selected, []); + + const expandedProps: FilePickerProps = { + ...collapsedProps, + expandedPaths: Object.freeze(["/", "/dir"]), + }; + assert.equal(routeFilePickerKeyDown(keyDown(ZR_KEY_LEFT), expandedProps, treeStore), true); + assert.deepEqual(toggled, ["/dir:true", "/dir:false"]); + + const fileProps: FilePickerProps = { + ...collapsedProps, + selectedPath: "/b.txt", + }; + assert.equal(routeFilePickerKeyDown(keyDown(ZR_KEY_ENTER), fileProps, treeStore), true); + assert.deepEqual(opened, ["/b.txt"]); + assert.deepEqual(toggled, ["/dir:true", "/dir:false"]); + + treeStore.set(collapsedProps.id, { focusedKey: "/b.txt" }); + assert.equal(routeFilePickerKeyDown(keyDown(ZR_KEY_DOWN), collapsedProps, treeStore), true); + assert.deepEqual(selected, []); + }); + + test("stale flat-cache data is ignored after data identity changes", () => { + const treeStore = createTreeStateStore(); + const opened: string[] = []; + const makeData = (leafPath: string): FileNode => + Object.freeze({ + name: "root", + path: "/", + type: "directory" as const, + children: Object.freeze([ + Object.freeze({ name: leafPath.slice(1), path: leafPath, type: "file" as const }), + ]), + }); + + const first: FilePickerProps = { + id: "fp-stale", + rootPath: "/", + data: makeData("/old.txt"), + expandedPaths: Object.freeze(["/"]), + selectedPath: "/old.txt", + onSelect: () => {}, + onToggle: () => {}, + onOpen: (path) => opened.push(path), + }; + + const second: FilePickerProps = { + ...first, + data: makeData("/new.txt"), + selectedPath: "/new.txt", + }; + + assert.equal(routeFilePickerKeyDown(keyDown(ZR_KEY_ENTER), first, treeStore), true); + assert.equal(routeFilePickerKeyDown(keyDown(ZR_KEY_ENTER), second, treeStore), true); + assert.deepEqual(opened, ["/old.txt", "/new.txt"]); + }); +}); + +describe("file tree explorer routing contracts", () => { + test("uses focused/selected fallback for Enter and ArrowDown", () => { + const treeStore = createTreeStateStore(); + const selected: string[] = []; + const toggled: string[] = []; + const activated: string[] = []; + + const props: FileTreeExplorerProps = { + id: "fte-fallback", + data: createFileTreeData(), + expanded: Object.freeze(["/"]), + focused: "/a.txt", + selected: "/dir", + onSelect: (node) => selected.push(node.path), + onToggle: (node, next) => toggled.push(`${node.path}:${String(next)}`), + onActivate: (node) => activated.push(node.path), + }; + + assert.equal(routeFileTreeExplorerKeyDown(keyDown(ZR_KEY_ENTER), props, treeStore), true); + assert.deepEqual(activated, ["/a.txt"]); + assert.deepEqual(selected, []); + assert.deepEqual(toggled, []); + + assert.equal(routeFileTreeExplorerKeyDown(keyDown(ZR_KEY_DOWN), props, treeStore), true); + assert.deepEqual(selected, ["/dir"]); + assert.equal(treeStore.get(props.id).focusedKey, "/dir"); + }); + + test("expand/collapse + activate callbacks are deterministic", () => { + const treeStore = createTreeStateStore(); + const selected: string[] = []; + const toggled: string[] = []; + const activated: string[] = []; + + const collapsedProps: FileTreeExplorerProps = { + id: "fte-callbacks", + data: createFileTreeData(), + expanded: Object.freeze(["/"]), + selected: "/dir", + onSelect: (node) => selected.push(node.path), + onToggle: (node, next) => toggled.push(`${node.path}:${String(next)}`), + onActivate: (node) => activated.push(node.path), + }; + + assert.equal( + routeFileTreeExplorerKeyDown(keyDown(ZR_KEY_RIGHT), collapsedProps, treeStore), + true, + ); + assert.deepEqual(toggled, ["/dir:true"]); + assert.deepEqual(activated, []); + assert.deepEqual(selected, []); + + const expandedProps: FileTreeExplorerProps = { + ...collapsedProps, + expanded: Object.freeze(["/", "/dir"]), + }; + assert.equal( + routeFileTreeExplorerKeyDown(keyDown(ZR_KEY_LEFT), expandedProps, treeStore), + true, + ); + assert.deepEqual(toggled, ["/dir:true", "/dir:false"]); + + const fileProps: FileTreeExplorerProps = { + ...collapsedProps, + selected: "/b.txt", + }; + assert.equal(routeFileTreeExplorerKeyDown(keyDown(ZR_KEY_ENTER), fileProps, treeStore), true); + assert.deepEqual(activated, ["/b.txt"]); + assert.deepEqual(toggled, ["/dir:true", "/dir:false"]); + + treeStore.set(collapsedProps.id, { focusedKey: "/b.txt" }); + assert.equal( + routeFileTreeExplorerKeyDown(keyDown(ZR_KEY_DOWN), collapsedProps, treeStore), + true, + ); + assert.deepEqual(selected, []); + }); + + test("stale flat-cache data is ignored after data identity changes", () => { + const treeStore = createTreeStateStore(); + const activated: string[] = []; + const makeData = (leafPath: string): FileNode => + Object.freeze({ + name: "root", + path: "/", + type: "directory" as const, + children: Object.freeze([ + Object.freeze({ name: leafPath.slice(1), path: leafPath, type: "file" as const }), + ]), + }); + + const first: FileTreeExplorerProps = { + id: "fte-stale", + data: makeData("/old.txt"), + expanded: Object.freeze(["/"]), + selected: "/old.txt", + onSelect: () => {}, + onToggle: () => {}, + onActivate: (node) => activated.push(node.path), + }; + + const second: FileTreeExplorerProps = { + ...first, + data: makeData("/new.txt"), + selected: "/new.txt", + }; + + assert.equal(routeFileTreeExplorerKeyDown(keyDown(ZR_KEY_ENTER), first, treeStore), true); + assert.equal(routeFileTreeExplorerKeyDown(keyDown(ZR_KEY_ENTER), second, treeStore), true); + assert.deepEqual(activated, ["/old.txt", "/new.txt"]); + }); +}); diff --git a/packages/core/src/app/__tests__/fileTreeExplorer.contextMenu.test.ts b/packages/core/src/app/__tests__/fileTreeExplorer.contextMenu.test.ts index e7a2eae4..5e598bcd 100644 --- a/packages/core/src/app/__tests__/fileTreeExplorer.contextMenu.test.ts +++ b/packages/core/src/app/__tests__/fileTreeExplorer.contextMenu.test.ts @@ -91,4 +91,49 @@ describe("FileTreeExplorer context menu", () => { renderer.routeEngineEvent(mouseEvent(0, 0, 3, { buttons: 1 })); assert.deepEqual(calls, ["/b"]); }); + + test("right click mapping is row-stable and ignores rows with no backing node", () => { + const backend = createNoopBackend(); + const renderer = new WidgetRenderer({ + backend, + requestRender: () => {}, + }); + + const calls: string[] = []; + + const data: FileNode = Object.freeze({ + name: "root", + path: "/", + type: "directory", + children: Object.freeze([ + Object.freeze({ name: "a", path: "/a", type: "file" }), + Object.freeze({ name: "b", path: "/b", type: "file" }), + ]), + }); + + const vnode = ui.fileTreeExplorer({ + id: "fte", + data, + expanded: ["/"], + onToggle: () => {}, + onSelect: () => {}, + onActivate: () => {}, + onContextMenu: (node) => calls.push(node.path), + }); + + const res = renderer.submitFrame( + () => vnode, + undefined, + { cols: 20, rows: 5 }, + defaultTheme, + noRenderHooks(), + ); + assert.ok(res.ok); + + renderer.routeEngineEvent(mouseEvent(0, 1, 3, { buttons: 4 })); + renderer.routeEngineEvent(mouseEvent(0, 2, 3, { buttons: 4 })); + renderer.routeEngineEvent(mouseEvent(0, 4, 3, { buttons: 4 })); + + assert.deepEqual(calls, ["/a", "/b"]); + }); }); diff --git a/packages/core/src/app/__tests__/table.renderCache.test.ts b/packages/core/src/app/__tests__/table.renderCache.test.ts new file mode 100644 index 00000000..8e1d5919 --- /dev/null +++ b/packages/core/src/app/__tests__/table.renderCache.test.ts @@ -0,0 +1,145 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { TableProps } from "../../widgets/types.js"; +import { type TableRenderCache, rebuildRenderCaches } from "../widgetRenderer/renderCaches.js"; + +function createTableProps( + data: readonly unknown[], + getRowKey: (row: unknown, index: number) => string, + selection: readonly string[] = [], +): TableProps { + return { + id: "t", + columns: [{ key: "id", header: "ID", flex: 1 }], + data, + getRowKey, + selection, + }; +} + +function rebuildTableCaches( + tableById: ReadonlyMap>, + tableRenderCacheById: Map, +): void { + rebuildRenderCaches({ + tableById, + logsConsoleById: new Map(), + diffViewerById: new Map(), + codeEditorById: new Map(), + tableRenderCacheById, + logsConsoleRenderCacheById: new Map(), + diffRenderCacheById: new Map(), + codeEditorRenderCacheById: new Map(), + emptyStringArray: Object.freeze([]), + }); +} + +describe("table render cache contracts", () => { + test("reuses rowKeys and rowKeyToIndex when data/getRowKey refs are stable", () => { + const data = Object.freeze([{ id: "a" }, { id: "b" }, { id: "c" }]); + let getRowKeyCalls = 0; + const getRowKey = (row: unknown): string => { + getRowKeyCalls++; + return (row as { id: string }).id; + }; + + const tableById = new Map>([ + ["t", createTableProps(data, getRowKey)], + ]); + const cacheById = new Map(); + + rebuildTableCaches(tableById, cacheById); + const first = cacheById.get("t"); + assert.ok(first !== undefined); + assert.equal(getRowKeyCalls, 3); + + rebuildTableCaches(tableById, cacheById); + const second = cacheById.get("t"); + assert.ok(second !== undefined); + + assert.equal(getRowKeyCalls, 3); + assert.equal(second.rowKeys, first.rowKeys); + assert.equal(second.rowKeyToIndex, first.rowKeyToIndex); + }); + + test("rebuilds row key caches when data reference changes", () => { + const dataA = Object.freeze([{ id: "a" }, { id: "b" }, { id: "c" }]); + const dataB = Object.freeze([{ id: "a" }, { id: "b" }, { id: "c" }]); + let getRowKeyCalls = 0; + const getRowKey = (row: unknown): string => { + getRowKeyCalls++; + return (row as { id: string }).id; + }; + + const tableById = new Map>([ + ["t", createTableProps(dataA, getRowKey)], + ]); + const cacheById = new Map(); + + rebuildTableCaches(tableById, cacheById); + const first = cacheById.get("t"); + assert.ok(first !== undefined); + assert.equal(getRowKeyCalls, 3); + + tableById.set("t", createTableProps(dataB, getRowKey)); + rebuildTableCaches(tableById, cacheById); + const second = cacheById.get("t"); + assert.ok(second !== undefined); + + assert.equal(getRowKeyCalls, 6); + assert.notEqual(second.rowKeys, first.rowKeys); + assert.notEqual(second.rowKeyToIndex, first.rowKeyToIndex); + assert.deepEqual([...second.rowKeys], ["a", "b", "c"]); + assert.equal(second.rowKeyToIndex.get("a"), 0); + assert.equal(second.rowKeyToIndex.get("b"), 1); + assert.equal(second.rowKeyToIndex.get("c"), 2); + }); + + test("row key/index caches follow reordered data deterministically", () => { + const a = Object.freeze({ id: "a" }); + const b = Object.freeze({ id: "b" }); + const c = Object.freeze({ id: "c" }); + + const getRowKey = (row: unknown): string => (row as { id: string }).id; + const tableById = new Map>([ + ["t", createTableProps(Object.freeze([a, b, c]), getRowKey)], + ]); + const cacheById = new Map(); + + rebuildTableCaches(tableById, cacheById); + + tableById.set("t", createTableProps(Object.freeze([c, a, b]), getRowKey)); + rebuildTableCaches(tableById, cacheById); + const reordered = cacheById.get("t"); + assert.ok(reordered !== undefined); + + assert.deepEqual([...reordered.rowKeys], ["c", "a", "b"]); + assert.equal(reordered.rowKeyToIndex.get("c"), 0); + assert.equal(reordered.rowKeyToIndex.get("a"), 1); + assert.equal(reordered.rowKeyToIndex.get("b"), 2); + }); + + test("selectionSet fast-path is keyed by selection reference", () => { + const data = Object.freeze([{ id: "a" }, { id: "b" }]); + const getRowKey = (row: unknown): string => (row as { id: string }).id; + const stableSelection = Object.freeze(["a"]); + const tableById = new Map>([ + ["t", createTableProps(data, getRowKey, stableSelection)], + ]); + const cacheById = new Map(); + + rebuildTableCaches(tableById, cacheById); + const first = cacheById.get("t"); + assert.ok(first !== undefined); + + rebuildTableCaches(tableById, cacheById); + const second = cacheById.get("t"); + assert.ok(second !== undefined); + assert.equal(second.selectionSet, first.selectionSet); + + tableById.set("t", createTableProps(data, getRowKey, Object.freeze(["a"]))); + rebuildTableCaches(tableById, cacheById); + const third = cacheById.get("t"); + assert.ok(third !== undefined); + assert.notEqual(third.selectionSet, second.selectionSet); + }); +}); diff --git a/packages/core/src/app/widgetRenderer/filePickerRouting.ts b/packages/core/src/app/widgetRenderer/filePickerRouting.ts index c03dbcee..62fd80e7 100644 --- a/packages/core/src/app/widgetRenderer/filePickerRouting.ts +++ b/packages/core/src/app/widgetRenderer/filePickerRouting.ts @@ -42,11 +42,17 @@ export function routeFileTreeExplorerKeyDown( return next; })(); + const routingFocusedKey = state.focusedKey ?? fte.focused ?? fte.selected ?? null; + const routingState = + routingFocusedKey === state.focusedKey + ? state + : Object.freeze({ ...state, focusedKey: routingFocusedKey }); + const r = routeTreeKey(event, { treeId: fte.id, flatNodes, expanded: fte.expanded, - state, + state: routingState, keyboardNavigation: true, }); @@ -117,6 +123,11 @@ export function routeFilePickerKeyDown( const focusedKey = state.focusedKey ?? fp.selectedPath ?? fp.selection?.[0] ?? flatNodes[0]?.key ?? null; + const routingFocusedKey = state.focusedKey ?? fp.selectedPath ?? fp.selection?.[0] ?? null; + const routingState = + routingFocusedKey === state.focusedKey + ? state + : Object.freeze({ ...state, focusedKey: routingFocusedKey }); // Space toggles selection in multi-select mode. if ( @@ -137,7 +148,7 @@ export function routeFilePickerKeyDown( treeId: fp.id, flatNodes, expanded: fp.expandedPaths, - state, + state: routingState, keyboardNavigation: true, }); diff --git a/packages/core/src/runtime/__tests__/inputEditor.contract.test.ts b/packages/core/src/runtime/__tests__/inputEditor.contract.test.ts new file mode 100644 index 00000000..61ea70c2 --- /dev/null +++ b/packages/core/src/runtime/__tests__/inputEditor.contract.test.ts @@ -0,0 +1,359 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { ZrevEvent } from "../../events.js"; +import { applyInputEditEvent } from "../inputEditor.js"; + +/* ABI-pinned key/modifier codes (docs/protocol/abi.md). */ +const ZR_KEY_TAB = 3; +const ZR_KEY_BACKSPACE = 4; +const ZR_KEY_DELETE = 11; +const ZR_KEY_HOME = 12; +const ZR_KEY_END = 13; +const ZR_KEY_LEFT = 22; +const ZR_KEY_RIGHT = 23; + +const ZR_MOD_SHIFT = 1 << 0; +const ZR_MOD_CTRL = 1 << 1; +const ZR_MOD_ALT = 1 << 2; +const ZR_MOD_META = 1 << 3; + +function keyEvent( + key: number, + opts: Readonly<{ mods?: number; action?: "down" | "up" | "repeat" }> = {}, +): ZrevEvent { + return { + kind: "key", + timeMs: 0, + key, + mods: opts.mods ?? 0, + action: opts.action ?? "down", + }; +} + +function textEvent(codepoint: number): ZrevEvent { + return { kind: "text", timeMs: 0, codepoint }; +} + +function pasteEvent(bytes: Uint8Array): ZrevEvent { + return { kind: "paste", timeMs: 0, bytes }; +} + +function utf8Bytes(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +function edit( + event: ZrevEvent, + ctx: Readonly<{ id: string; value: string; cursor: number }>, +): ReturnType { + return applyInputEditEvent(event, ctx); +} + +function routeInputWithFocus( + event: ZrevEvent, + ctx: Readonly<{ + inputId: string; + focusedId: string | null; + enabledById: ReadonlyMap; + value: string; + cursor: number; + }>, +): Readonly<{ + consumed: boolean; + bubbled: boolean; + value: string; + cursor: number; + action?: Readonly<{ id: string; action: "input"; value: string; cursor: number }>; +}> { + const targetId = + ctx.focusedId === ctx.inputId && ctx.enabledById.get(ctx.focusedId) === true + ? ctx.focusedId + : null; + + if (targetId === null) { + return Object.freeze({ + consumed: false, + bubbled: true, + value: ctx.value, + cursor: ctx.cursor, + }); + } + + const next = edit(event, { + id: targetId, + value: ctx.value, + cursor: ctx.cursor, + }); + + if (!next) { + return Object.freeze({ + consumed: false, + bubbled: true, + value: ctx.value, + cursor: ctx.cursor, + }); + } + + return Object.freeze({ + consumed: true, + bubbled: false, + value: next.nextValue, + cursor: next.nextCursor, + ...(next.action ? { action: next.action } : {}), + }); +} + +describe("input editor contract", () => { + test("cursor movement: left/right/home/end are deterministic", () => { + const value = "a\u0301b🙂"; // boundaries: 0, 2, 3, 5 + const events: readonly ZrevEvent[] = [ + keyEvent(ZR_KEY_LEFT), + keyEvent(ZR_KEY_LEFT), + keyEvent(ZR_KEY_LEFT), + keyEvent(ZR_KEY_LEFT), + keyEvent(ZR_KEY_RIGHT), + keyEvent(ZR_KEY_RIGHT), + keyEvent(ZR_KEY_RIGHT), + keyEvent(ZR_KEY_RIGHT), + keyEvent(ZR_KEY_HOME), + keyEvent(ZR_KEY_END), + ]; + const expectedCursors = [3, 2, 0, 0, 2, 3, 5, 5, 0, 5]; + + let cursor = value.length; + for (let i = 0; i < events.length; i++) { + const ev = events[i]; + if (!ev) { + assert.fail(`missing event at index ${String(i)}`); + } + const next = edit(ev, { id: "input", value, cursor }); + assert.ok(next, `step ${String(i)} should be handled`); + if (!next) return; + assert.equal(next.nextValue, value, `step ${String(i)}: value`); + assert.equal(next.nextCursor, expectedCursors[i], `step ${String(i)}: cursor`); + assert.equal(next.action, undefined, `step ${String(i)}: action`); + cursor = next.nextCursor; + } + }); + + test("backspace/delete boundaries and edits are deterministic", () => { + const value = "a\u0301b"; + + const backspaceAtStart = edit(keyEvent(ZR_KEY_BACKSPACE), { + id: "input", + value, + cursor: 0, + }); + assert.deepEqual(backspaceAtStart, { nextValue: value, nextCursor: 0 }); + + const deleteAtEnd = edit(keyEvent(ZR_KEY_DELETE), { + id: "input", + value, + cursor: value.length, + }); + assert.deepEqual(deleteAtEnd, { nextValue: value, nextCursor: value.length }); + + const backspaceCluster = edit(keyEvent(ZR_KEY_BACKSPACE), { + id: "input", + value, + cursor: 2, + }); + assert.deepEqual(backspaceCluster, { + nextValue: "b", + nextCursor: 0, + action: { id: "input", action: "input", value: "b", cursor: 0 }, + }); + + const deleteFirstCluster = edit(keyEvent(ZR_KEY_DELETE), { + id: "input", + value, + cursor: 0, + }); + assert.deepEqual(deleteFirstCluster, { + nextValue: "b", + nextCursor: 0, + action: { id: "input", action: "input", value: "b", cursor: 0 }, + }); + + const deleteAfterCluster = edit(keyEvent(ZR_KEY_DELETE), { + id: "input", + value, + cursor: 2, + }); + assert.deepEqual(deleteAfterCluster, { + nextValue: "a\u0301", + nextCursor: 2, + action: { id: "input", action: "input", value: "a\u0301", cursor: 2 }, + }); + }); + + test("paste inserts multi-char text and updates cursor to insertion end", () => { + const asciiPaste = edit(pasteEvent(utf8Bytes("WXYZ")), { + id: "input", + value: "12", + cursor: 1, + }); + assert.deepEqual(asciiPaste, { + nextValue: "1WXYZ2", + nextCursor: 5, + action: { id: "input", action: "input", value: "1WXYZ2", cursor: 5 }, + }); + + const crlfStripped = edit(pasteEvent(utf8Bytes("A\r\nB")), { + id: "input", + value: "xy", + cursor: 1, + }); + assert.deepEqual(crlfStripped, { + nextValue: "xABy", + nextCursor: 3, + action: { id: "input", action: "input", value: "xABy", cursor: 3 }, + }); + + const unicodePaste = edit(pasteEvent(utf8Bytes("👍z")), { + id: "input", + value: "ab", + cursor: 1, + }); + assert.deepEqual(unicodePaste, { + nextValue: "a👍zb", + nextCursor: 4, + action: { id: "input", action: "input", value: "a👍zb", cursor: 4 }, + }); + }); + + test("focus capture: focused+enabled input consumes; otherwise events bubble", () => { + const enabledById = new Map([["input", true]]); + + const consumed = routeInputWithFocus(textEvent(120), { + inputId: "input", + focusedId: "input", + enabledById, + value: "", + cursor: 0, + }); + assert.deepEqual(consumed, { + consumed: true, + bubbled: false, + value: "x", + cursor: 1, + action: { id: "input", action: "input", value: "x", cursor: 1 }, + }); + + const navigationConsumed = routeInputWithFocus(keyEvent(ZR_KEY_LEFT), { + inputId: "input", + focusedId: "input", + enabledById, + value: "abc", + cursor: 2, + }); + assert.deepEqual(navigationConsumed, { + consumed: true, + bubbled: false, + value: "abc", + cursor: 1, + }); + + const notFocused = routeInputWithFocus(textEvent(120), { + inputId: "input", + focusedId: "other", + enabledById, + value: "", + cursor: 0, + }); + assert.deepEqual(notFocused, { + consumed: false, + bubbled: true, + value: "", + cursor: 0, + }); + + const disabled = routeInputWithFocus(textEvent(120), { + inputId: "input", + focusedId: "input", + enabledById: new Map([["input", false]]), + value: "", + cursor: 0, + }); + assert.deepEqual(disabled, { + consumed: false, + bubbled: true, + value: "", + cursor: 0, + }); + + const nonEditingKeyBubbles = routeInputWithFocus(keyEvent(ZR_KEY_TAB), { + inputId: "input", + focusedId: "input", + enabledById, + value: "abc", + cursor: 1, + }); + assert.deepEqual(nonEditingKeyBubbles, { + consumed: false, + bubbled: true, + value: "abc", + cursor: 1, + }); + }); + + test("modifier handling: modifiers do not alter supported edit/navigation keys", () => { + const mods = ZR_MOD_SHIFT | ZR_MOD_CTRL | ZR_MOD_ALT | ZR_MOD_META; + + const leftWithMods = edit(keyEvent(ZR_KEY_LEFT, { mods }), { + id: "input", + value: "abcd", + cursor: 2, + }); + assert.deepEqual(leftWithMods, { nextValue: "abcd", nextCursor: 1 }); + + const rightWithMods = edit(keyEvent(ZR_KEY_RIGHT, { mods }), { + id: "input", + value: "abcd", + cursor: 2, + }); + assert.deepEqual(rightWithMods, { nextValue: "abcd", nextCursor: 3 }); + + const homeWithMods = edit(keyEvent(ZR_KEY_HOME, { mods }), { + id: "input", + value: "abcd", + cursor: 3, + }); + assert.deepEqual(homeWithMods, { nextValue: "abcd", nextCursor: 0 }); + + const endWithMods = edit(keyEvent(ZR_KEY_END, { mods }), { + id: "input", + value: "abcd", + cursor: 1, + }); + assert.deepEqual(endWithMods, { nextValue: "abcd", nextCursor: 4 }); + + const backspaceWithMods = edit(keyEvent(ZR_KEY_BACKSPACE, { mods }), { + id: "input", + value: "abcd", + cursor: 2, + }); + assert.deepEqual(backspaceWithMods, { + nextValue: "acd", + nextCursor: 1, + action: { id: "input", action: "input", value: "acd", cursor: 1 }, + }); + + const deleteWithMods = edit(keyEvent(ZR_KEY_DELETE, { mods }), { + id: "input", + value: "abcd", + cursor: 1, + }); + assert.deepEqual(deleteWithMods, { + nextValue: "acd", + nextCursor: 1, + action: { id: "input", action: "input", value: "acd", cursor: 1 }, + }); + + const keyUpBubbles = edit(keyEvent(ZR_KEY_LEFT, { mods, action: "up" }), { + id: "input", + value: "abcd", + cursor: 2, + }); + assert.equal(keyUpBubbles, null); + }); +}); diff --git a/packages/core/src/runtime/router/table.ts b/packages/core/src/runtime/router/table.ts index 2a86b0bd..8dfd0075 100644 --- a/packages/core/src/runtime/router/table.ts +++ b/packages/core/src/runtime/router/table.ts @@ -38,31 +38,43 @@ export function routeTableKey(event: ZrevEvent, ctx: TableRoutingCtx): Tab if (rowCount === 0) return Object.freeze({ consumed: false }); + // Keep keyboard routing consistent with renderer math. + const safeRowHeight = rowHeight > 0 ? rowHeight : 1; + const safeViewportHeight = Math.max(0, viewportHeight); + const maxScrollTop = Math.max(0, rowCount * safeRowHeight - safeViewportHeight); + const normalizedScrollTop = Number.isFinite(scrollTop) + ? Math.max(0, Math.min(maxScrollTop, scrollTop)) + : 0; + const consumedNoMove = (): TableRoutingResult => + normalizedScrollTop !== scrollTop + ? Object.freeze({ consumed: true, nextScrollTop: normalizedScrollTop }) + : Object.freeze({ consumed: true }); + // Helper to compute scroll position for a given row index const scrollToRow = (rowIndex: number): number => { - const rowTop = rowIndex * rowHeight; - const rowBottom = rowTop + rowHeight; - const viewportBottom = scrollTop + viewportHeight; + const rowTop = rowIndex * safeRowHeight; + const rowBottom = rowTop + safeRowHeight; + const viewportBottom = normalizedScrollTop + safeViewportHeight; // If row is above viewport, scroll up - if (rowTop < scrollTop) { + if (rowTop < normalizedScrollTop) { return rowTop; } // If row is below viewport, scroll down if (rowBottom > viewportBottom) { - return Math.max(0, rowBottom - viewportHeight); + return Math.max(0, Math.min(maxScrollTop, rowBottom - safeViewportHeight)); } // Row is visible - return scrollTop; + return normalizedScrollTop; }; // Compute page size - const pageSize = Math.max(1, Math.floor(viewportHeight / rowHeight)); + const pageSize = Math.max(1, Math.floor(safeViewportHeight / safeRowHeight)); // Arrow Up if (event.key === ZR_KEY_UP) { const nextIndex = Math.max(0, focusedRowIndex - 1); - if (nextIndex === focusedRowIndex) return Object.freeze({ consumed: true }); + if (nextIndex === focusedRowIndex) return consumedNoMove(); const newScrollTop = scrollToRow(nextIndex); const result: TableRoutingResult = { @@ -79,7 +91,7 @@ export function routeTableKey(event: ZrevEvent, ctx: TableRoutingCtx): Tab // Arrow Down if (event.key === ZR_KEY_DOWN) { const nextIndex = Math.min(rowCount - 1, focusedRowIndex + 1); - if (nextIndex === focusedRowIndex) return Object.freeze({ consumed: true }); + if (nextIndex === focusedRowIndex) return consumedNoMove(); const newScrollTop = scrollToRow(nextIndex); const result: TableRoutingResult = { @@ -96,7 +108,7 @@ export function routeTableKey(event: ZrevEvent, ctx: TableRoutingCtx): Tab // Page Up if (event.key === ZR_KEY_PAGE_UP) { const nextIndex = Math.max(0, focusedRowIndex - pageSize); - if (nextIndex === focusedRowIndex) return Object.freeze({ consumed: true }); + if (nextIndex === focusedRowIndex) return consumedNoMove(); const newScrollTop = scrollToRow(nextIndex); const result: TableRoutingResult = { @@ -113,7 +125,7 @@ export function routeTableKey(event: ZrevEvent, ctx: TableRoutingCtx): Tab // Page Down if (event.key === ZR_KEY_PAGE_DOWN) { const nextIndex = Math.min(rowCount - 1, focusedRowIndex + pageSize); - if (nextIndex === focusedRowIndex) return Object.freeze({ consumed: true }); + if (nextIndex === focusedRowIndex) return consumedNoMove(); const newScrollTop = scrollToRow(nextIndex); const result: TableRoutingResult = { @@ -129,7 +141,7 @@ export function routeTableKey(event: ZrevEvent, ctx: TableRoutingCtx): Tab // Home if (event.key === ZR_KEY_HOME) { - if (focusedRowIndex === 0) return Object.freeze({ consumed: true }); + if (focusedRowIndex === 0) return consumedNoMove(); return Object.freeze({ nextFocusedRowIndex: 0, @@ -141,7 +153,7 @@ export function routeTableKey(event: ZrevEvent, ctx: TableRoutingCtx): Tab // End if (event.key === ZR_KEY_END) { const lastIndex = rowCount - 1; - if (focusedRowIndex === lastIndex) return Object.freeze({ consumed: true }); + if (focusedRowIndex === lastIndex) return consumedNoMove(); const newScrollTop = scrollToRow(lastIndex); const result: TableRoutingResult = { diff --git a/packages/core/src/widgets/__tests__/commandPalette.test.ts b/packages/core/src/widgets/__tests__/commandPalette.test.ts index 665b2833..6af62760 100644 --- a/packages/core/src/widgets/__tests__/commandPalette.test.ts +++ b/packages/core/src/widgets/__tests__/commandPalette.test.ts @@ -1,6 +1,20 @@ import { assert, test } from "@rezi-ui/testkit"; import { computeCommandPaletteWindow, getFilteredItems, sortByScore } from "../commandPalette.js"; +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve: ((value: T) => void) | undefined; + const promise = new Promise((res) => { + resolve = res; + }); + return { + promise, + resolve: (value: T) => resolve?.(value), + }; +} + test("commandPalette: computeCommandPaletteWindow keeps selection visible", () => { assert.deepEqual(computeCommandPaletteWindow(0, 0, 10), { start: 0, count: 0 }); assert.deepEqual(computeCommandPaletteWindow(0, 5, 10), { start: 0, count: 5 }); @@ -42,3 +56,37 @@ test("commandPalette: sortByScore keeps original order for equal scores", () => ["open", "opal"], ); }); + +test("commandPalette: async sources keep declared source order regardless of resolution order", async () => { + const commands = + createDeferred[]>(); + const symbols = + createDeferred[]>(); + + const sources = [ + { + id: "commands", + name: "Commands", + getItems: () => commands.promise, + }, + { + id: "symbols", + name: "Symbols", + getItems: () => symbols.promise, + }, + ] as const; + + const pending = getFilteredItems(sources, ""); + + symbols.resolve([{ id: "sym-1", label: "Symbol One", sourceId: "symbols" }] as const); + commands.resolve([ + { id: "cmd-1", label: "Command One", sourceId: "commands" }, + { id: "cmd-2", label: "Command Two", sourceId: "commands" }, + ] as const); + + const items = await pending; + assert.deepEqual( + items.map((item) => item.id), + ["cmd-1", "cmd-2", "sym-1"], + ); +}); diff --git a/packages/core/src/widgets/__tests__/table.golden.test.ts b/packages/core/src/widgets/__tests__/table.golden.test.ts index ab8f9eda..e642f5b6 100644 --- a/packages/core/src/widgets/__tests__/table.golden.test.ts +++ b/packages/core/src/widgets/__tests__/table.golden.test.ts @@ -18,7 +18,6 @@ import { ZR_KEY_PAGE_UP, ZR_KEY_SPACE, ZR_KEY_UP, - ZR_MOD_CTRL, ZR_MOD_SHIFT, } from "../../keybindings/keyCodes.js"; import type { TableLocalState } from "../../runtime/localState.js"; @@ -28,6 +27,7 @@ import { SORT_INDICATOR_DESC, type TableColumn, alignCellText, + buildRowKeyIndex, clearSelection, computeSelection, distributeColumnWidths, @@ -64,6 +64,7 @@ function createTableCtx( return { tableId: overrides.tableId ?? "test-table", rowKeys, + ...(overrides.rowKeyToIndex ? { rowKeyToIndex: overrides.rowKeyToIndex } : {}), data, rowHeight: overrides.rowHeight ?? 1, state, @@ -73,9 +74,6 @@ function createTableCtx( }; } -/* ========== Key Code Constants ========== */ -const ZR_KEY_A = 65; - /* ========== Column Width Distribution Tests ========== */ describe("table - distributeColumnWidths", () => { @@ -154,6 +152,38 @@ describe("table - distributeColumnWidths", () => { assert.equal(result.widths[0], 50); assert.equal(result.widths[1], 50); }); + + test("fractional flex widths are deterministic across runs", () => { + const columns: TableColumn[] = [ + { key: "a", header: "A", flex: 1 }, + { key: "b", header: "B", flex: 1 }, + { key: "c", header: "C", flex: 1 }, + ]; + + const first = distributeColumnWidths(columns, 10); + const second = distributeColumnWidths(columns, 10); + + assert.deepEqual([...first.widths], [3, 3, 3]); + assert.deepEqual([...second.widths], [3, 3, 3]); + assert.equal(first.totalWidth, 9); + assert.equal(second.totalWidth, 9); + }); + + test("mixed fixed/flex/min/max distribution is deterministic", () => { + const columns: TableColumn[] = [ + { key: "a", header: "A", flex: 3, minWidth: 10 }, + { key: "b", header: "B", flex: 1, maxWidth: 5 }, + { key: "c", header: "C", width: 7 }, + ]; + + const first = distributeColumnWidths(columns, 31); + const second = distributeColumnWidths(columns, 31); + + assert.deepEqual([...first.widths], [18, 5, 7]); + assert.deepEqual([...second.widths], [18, 5, 7]); + assert.equal(first.totalWidth, 30); + assert.equal(second.totalWidth, 30); + }); }); /* ========== Selection Tests ========== */ @@ -251,6 +281,29 @@ describe("table - selection", () => { assert.equal(result.changed, true); }); + test("shift+click uses rowKeyToIndex fast-path (including index 0)", () => { + const keys = ["r1", "r2", "r3", "r4"]; + Object.defineProperty(keys, "indexOf", { + value: () => { + throw new Error("indexOf fallback should not be used when map lookup succeeds"); + }, + }); + const index = buildRowKeyIndex(keys); + + const result = computeSelection( + ["r1"], + "r3", + "multi", + { shift: true, ctrl: false }, + keys, + "r1", + index, + ); + + assert.deepEqual([...result.selection], ["r1", "r2", "r3"]); + assert.equal(result.changed, true); + }); + test("none mode: always returns empty selection", () => { const result = computeSelection( ["r1"], @@ -379,6 +432,17 @@ describe("table - extractRowKeys", () => { assert.equal(keys.length, 3); }); + + test("stable row keys follow row identity after reorder", () => { + const rows = [{ id: "a" }, { id: "b" }, { id: "c" }] as const; + const reordered = [rows[2], rows[0], rows[1]]; + + const first = extractRowKeys(rows, (row) => row.id); + const second = extractRowKeys(reordered, (row) => row.id); + + assert.deepEqual([...first], ["a", "b", "c"]); + assert.deepEqual([...second], ["c", "a", "b"]); + }); }); /* ========== Row Navigation Helpers ========== */ @@ -477,6 +541,53 @@ describe("table - keyboard navigation", () => { assert.equal(result.consumed, true); }); + test("navigation keys do not mutate selection in multi mode", () => { + const ctx = createTableCtx({ + focusedRowIndex: 2, + selection: ["r1", "r3"], + selectionMode: "multi", + viewportHeight: 3, + rowHeight: 1, + rowKeys: ["r1", "r2", "r3", "r4", "r5", "r6"], + }); + + for (const key of [ + ZR_KEY_UP, + ZR_KEY_DOWN, + ZR_KEY_PAGE_UP, + ZR_KEY_PAGE_DOWN, + ZR_KEY_HOME, + ZR_KEY_END, + ]) { + const result = routeTableKey(createKeyEvent(key), ctx); + assert.equal(result.nextSelection, undefined); + assert.equal(result.consumed, true); + } + }); + + test("shift+space selection uses rowKeyToIndex fast-path", () => { + const rowKeys = ["r1", "r2", "r3", "r4"]; + Object.defineProperty(rowKeys, "indexOf", { + value: () => { + throw new Error("indexOf fallback should not be used when map lookup succeeds"); + }, + }); + + const ctx = createTableCtx({ + rowKeys, + focusedRowIndex: 2, + lastClickedKey: "r1", + selection: ["r1"], + selectionMode: "multi", + rowKeyToIndex: buildRowKeyIndex(rowKeys), + }); + const result = routeTableKey(createKeyEvent(ZR_KEY_SPACE, "down", ZR_MOD_SHIFT), ctx); + + assert.deepEqual(result.nextSelection, ["r1", "r2", "r3"]); + assert.equal(result.nextLastClickedKey, "r3"); + assert.equal(result.consumed, true); + }); + test("page down moves by page", () => { const ctx = createTableCtx({ focusedRowIndex: 0, @@ -543,4 +654,32 @@ describe("table - scroll into view on navigation", () => { assert.equal(result.nextFocusedRowIndex, 4); assert.equal(result.nextScrollTop, 4); }); + + test("stale scrollTop is clamped when viewport grows (resize contract)", () => { + const ctx = createTableCtx({ + focusedRowIndex: 90, + scrollTop: 50, + viewportHeight: 80, + rowHeight: 1, + rowKeys: Array.from({ length: 100 }, (_, i) => `r${i}`), + }); + const result = routeTableKey(createKeyEvent(ZR_KEY_DOWN), ctx); + + assert.equal(result.nextFocusedRowIndex, 91); + assert.equal(result.nextScrollTop, 20); + }); + + test("non-positive rowHeight uses safe keyboard page size", () => { + const ctx = createTableCtx({ + focusedRowIndex: 0, + scrollTop: 0, + viewportHeight: 4, + rowHeight: 0, + rowKeys: ["r1", "r2", "r3", "r4", "r5", "r6", "r7", "r8", "r9", "r10"], + }); + const result = routeTableKey(createKeyEvent(ZR_KEY_PAGE_DOWN), ctx); + + assert.equal(result.nextFocusedRowIndex, 4); + assert.equal(result.nextScrollTop, 1); + }); }); diff --git a/packages/core/src/widgets/__tests__/virtualList.contract.test.ts b/packages/core/src/widgets/__tests__/virtualList.contract.test.ts new file mode 100644 index 00000000..66097ae8 --- /dev/null +++ b/packages/core/src/widgets/__tests__/virtualList.contract.test.ts @@ -0,0 +1,183 @@ +/** + * packages/core/src/widgets/__tests__/virtualList.contract.test.ts + * + * Deterministic contract tests for virtual list range/window and navigation behavior. + */ + +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { ZrevEvent } from "../../events.js"; +import { ZR_KEY_END, ZR_KEY_PAGE_DOWN, ZR_KEY_PAGE_UP } from "../../keybindings/keyCodes.js"; +import type { VirtualListLocalState } from "../../runtime/localState.js"; +import { type VirtualListRoutingCtx, routeVirtualListKey } from "../../runtime/router.js"; +import { computeVisibleRange } from "../virtualList.js"; + +function createKeyEvent(key: number, action: "down" | "up" = "down"): ZrevEvent { + return { kind: "key", timeMs: 0, key, mods: 0, action }; +} + +function createTestCtx( + overrides: Partial> & Partial = {}, +): VirtualListRoutingCtx { + const items = overrides.items ?? Array.from({ length: 100 }, (_, i) => i); + const state: VirtualListLocalState = { + scrollTop: overrides.scrollTop ?? 0, + selectedIndex: overrides.selectedIndex ?? 0, + viewportHeight: overrides.viewportHeight ?? 10, + startIndex: overrides.startIndex ?? 0, + endIndex: overrides.endIndex ?? 0, + }; + + return { + virtualListId: overrides.virtualListId ?? "list", + items, + itemHeight: overrides.itemHeight ?? 1, + state, + keyboardNavigation: overrides.keyboardNavigation ?? true, + wrapAround: overrides.wrapAround ?? false, + }; +} + +describe("virtualList contracts - visible range/window", () => { + test("fixed-height window tracks scrollTop changes with overscan", () => { + const items = Array.from({ length: 20 }, (_, i) => i); + const expected = [ + { scrollTop: 0, startIndex: 0, endIndex: 7 }, + { scrollTop: 1, startIndex: 0, endIndex: 8 }, + { scrollTop: 2, startIndex: 0, endIndex: 9 }, + { scrollTop: 5, startIndex: 3, endIndex: 12 }, + { scrollTop: 14, startIndex: 12, endIndex: 20 }, + { scrollTop: 15, startIndex: 13, endIndex: 20 }, + ] as const; + + for (const contract of expected) { + const result = computeVisibleRange(items, 1, contract.scrollTop, 5, 2); + assert.equal(result.startIndex, contract.startIndex); + assert.equal(result.endIndex, contract.endIndex); + } + }); + + test("overscan expands both ends when viewport is away from list bounds", () => { + const items = Array.from({ length: 30 }, (_, i) => i); + const base = computeVisibleRange(items, 1, 6, 5, 0); + const withOverscan = computeVisibleRange(items, 1, 6, 5, 2); + + assert.equal(base.startIndex, 6); + assert.equal(base.endIndex, 11); + assert.equal(withOverscan.startIndex, 4); + assert.equal(withOverscan.endIndex, 13); + }); + + test("variable-height window remains deterministic across scrollTop changes", () => { + const items = [2, 1, 3, 1, 2]; + const itemHeight = (item: number) => item; + + const atTop = computeVisibleRange(items, itemHeight, 0, 3, 1); + const middle = computeVisibleRange(items, itemHeight, 2, 3, 1); + const atEnd = computeVisibleRange(items, itemHeight, 6, 3, 1); + + assert.equal(atTop.startIndex, 0); + assert.equal(atTop.endIndex, 3); + assert.equal(middle.startIndex, 0); + assert.equal(middle.endIndex, 4); + assert.equal(atEnd.startIndex, 2); + assert.equal(atEnd.endIndex, 5); + }); +}); + +describe("virtualList contracts - computeVisibleRange clamps scrollTop", () => { + test("negative scrollTop is clamped to zero", () => { + const fixedItems = Array.from({ length: 10 }, (_, i) => i); + const fixedAtZero = computeVisibleRange(fixedItems, 2, 0, 4, 1); + const fixedNegative = computeVisibleRange(fixedItems, 2, -100, 4, 1); + + assert.equal(fixedNegative.startIndex, fixedAtZero.startIndex); + assert.equal(fixedNegative.endIndex, fixedAtZero.endIndex); + + const variableItems = Array.from({ length: 10 }, (_, i) => i); + const variableHeight = () => 1; + const variableAtZero = computeVisibleRange(variableItems, variableHeight, 0, 4, 1); + const variableNegative = computeVisibleRange(variableItems, variableHeight, -100, 4, 1); + + assert.equal(variableNegative.startIndex, variableAtZero.startIndex); + assert.equal(variableNegative.endIndex, variableAtZero.endIndex); + }); + + test("scrollTop beyond max is clamped to end of content", () => { + const fixedItems = Array.from({ length: 10 }, (_, i) => i); + const fixedAtEnd = computeVisibleRange(fixedItems, 2, 16, 4, 1); + const fixedTooHigh = computeVisibleRange(fixedItems, 2, 1000, 4, 1); + + assert.equal(fixedTooHigh.startIndex, fixedAtEnd.startIndex); + assert.equal(fixedTooHigh.endIndex, fixedAtEnd.endIndex); + assert.ok(fixedTooHigh.startIndex <= fixedTooHigh.endIndex); + + const variableItems = Array.from({ length: 10 }, (_, i) => i); + const variableHeight = () => 1; + const variableAtEnd = computeVisibleRange(variableItems, variableHeight, 6, 4, 1); + const variableTooHigh = computeVisibleRange(variableItems, variableHeight, 1000, 4, 1); + + assert.equal(variableTooHigh.startIndex, variableAtEnd.startIndex); + assert.equal(variableTooHigh.endIndex, variableAtEnd.endIndex); + }); +}); + +describe("virtualList contracts - keyboard navigation", () => { + test("page down uses visible span when it is available", () => { + const items = Array.from({ length: 30 }, (_, i) => i); + const ctx = createTestCtx({ + items, + itemHeight: () => 2, + selectedIndex: 3, + viewportHeight: 9, + startIndex: 10, + endIndex: 14, + }); + + const result = routeVirtualListKey(createKeyEvent(ZR_KEY_PAGE_DOWN), ctx); + assert.equal(result.nextSelectedIndex, 7); + }); + + test("page-size fallback is used when visible range span is zero", () => { + const items = Array.from({ length: 30 }, (_, i) => i); + const downCtx = createTestCtx({ + items, + itemHeight: () => 2, + selectedIndex: 3, + scrollTop: 0, + viewportHeight: 9, + startIndex: 8, + endIndex: 8, + }); + + const down = routeVirtualListKey(createKeyEvent(ZR_KEY_PAGE_DOWN), downCtx); + assert.equal(down.nextSelectedIndex, 7); + assert.equal(down.nextScrollTop, 7); + + const upCtx = createTestCtx({ + items, + itemHeight: () => 2, + selectedIndex: 7, + scrollTop: 7, + viewportHeight: 9, + startIndex: 8, + endIndex: 8, + }); + + const up = routeVirtualListKey(createKeyEvent(ZR_KEY_PAGE_UP), upCtx); + assert.equal(up.nextSelectedIndex, 3); + }); + + test("end key jumps to last item and scrolls to max", () => { + const items = Array.from({ length: 10 }, (_, i) => i); + const ctx = createTestCtx({ + items, + itemHeight: 2, + selectedIndex: 0, + viewportHeight: 5, + }); + + const result = routeVirtualListKey(createKeyEvent(ZR_KEY_END), ctx); + assert.equal(result.nextSelectedIndex, 9); + assert.equal(result.nextScrollTop, 15); + }); +}); diff --git a/packages/core/src/widgets/virtualList.ts b/packages/core/src/widgets/virtualList.ts index 64ded62e..726d3ffe 100644 --- a/packages/core/src/widgets/virtualList.ts +++ b/packages/core/src/widgets/virtualList.ts @@ -139,9 +139,12 @@ export function computeVisibleRange( // Fixed height: O(1) calculation without building full offset array if (typeof itemHeight === "number") { + const totalHeight = n * itemHeight; + const clampedScrollTop = clampScrollTop(scrollTop, totalHeight, viewportHeight); + // Direct calculation: startIndex = floor(scrollTop / h), endIndex = ceil((scrollTop + viewport) / h) - const rawStart = Math.floor(scrollTop / itemHeight); - const rawEnd = Math.ceil((scrollTop + viewportHeight) / itemHeight); + const rawStart = Math.floor(clampedScrollTop / itemHeight); + const rawEnd = Math.ceil((clampedScrollTop + viewportHeight) / itemHeight); // Apply overscan and clamp to valid bounds const startIndex = Math.max(0, rawStart - overscan); @@ -164,12 +167,14 @@ export function computeVisibleRange( // Variable height: O(n) offset building + binary search const offsets = buildOffsets(items, itemHeight); + const totalHeight = offsets[n] ?? 0; + const clampedScrollTop = clampScrollTop(scrollTop, totalHeight, viewportHeight); // Find first visible item (where item's bottom edge > scrollTop) - const rawStart = binarySearchStart(offsets, scrollTop); + const rawStart = binarySearchStart(offsets, clampedScrollTop); // Find last visible item (where item's top edge < scrollTop + viewportHeight) - const rawEnd = binarySearchEnd(offsets, scrollTop + viewportHeight); + const rawEnd = binarySearchEnd(offsets, clampedScrollTop + viewportHeight); // Apply overscan and clamp to valid bounds const startIndex = Math.max(0, rawStart - overscan);