diff --git a/docs/widgets/command-palette.md b/docs/widgets/command-palette.md index 5ea95a59..a60ba581 100644 --- a/docs/widgets/command-palette.md +++ b/docs/widgets/command-palette.md @@ -9,6 +9,9 @@ ui.commandPalette({ id: "palette", open: state.open, query: state.query, + placeholder: "Search files and commands...", + maxVisible: 8, + loading: state.commandsLoading, sources: [ { id: "cmd", @@ -25,9 +28,28 @@ ui.commandPalette({ }) ``` +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `id` | `string` | **required** | Widget identifier. | +| `open` | `boolean` | **required** | Whether the palette is visible. | +| `query` | `string` | **required** | Current search query. | +| `sources` | `CommandSource[]` | **required** | Command providers used to produce results. | +| `selectedIndex` | `number` | **required** | Currently highlighted result index. | +| `loading` | `boolean` | `false` | Shows a loading indicator in the input area. Async `getItems` fetches also set loading internally while pending. | +| `placeholder` | `string` | `"Search commands..."` | Text shown when `query` is empty. Keep it short so the input stays readable on narrow terminals. | +| `maxVisible` | `number` | `10` | Maximum visible result rows. Useful for capping palette height in smaller viewports. | +| `frameStyle` | `{ background?, foreground?, border? }` | - | Optional frame/surface colors for palette background, text, and border. | +| `onQueryChange` | `(query) => void` | **required** | Called when the query changes. | +| `onSelect` | `(item) => void` | **required** | Called when a result item is selected. | +| `onClose` | `() => void` | **required** | Called when the palette should close. | +| `onSelectionChange` | `(index) => void` | - | Called when highlighted index changes. | + ## Notes - Command sources can be sync or async (`getItems` may return a `Promise`). - Keep `query` and `selectedIndex` in your app state (controlled pattern). +- `maxVisible` accepts non-negative values; `0` hides the results list while keeping the palette input visible. Next: [`Code editor`](code-editor.md). diff --git a/docs/widgets/dropdown.md b/docs/widgets/dropdown.md index b397fc5c..0f39b732 100644 --- a/docs/widgets/dropdown.md +++ b/docs/widgets/dropdown.md @@ -20,6 +20,18 @@ ui.dropdown({ }) ``` +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `id` | `string` | **required** | Unique dropdown identifier | +| `anchorId` | `string` | **required** | ID of anchor widget used for positioning | +| `position` | `DropdownPosition` | `"below-start"` | Placement relative to anchor | +| `items` | `DropdownItem[]` | **required** | Menu rows (including optional dividers) | +| `frameStyle` | `{ background?, foreground?, border? }` | - | Optional frame/surface colors for menu background, text, and border | +| `onSelect` | `(item) => void` | - | Called when a selectable item is activated | +| `onClose` | `() => void` | - | Called when dropdown should close | + ## Behavior - **Arrow keys** navigate items. **Enter** selects the highlighted item. diff --git a/docs/widgets/index.md b/docs/widgets/index.md index 92f58c2e..a59cb2cc 100644 --- a/docs/widgets/index.md +++ b/docs/widgets/index.md @@ -49,6 +49,7 @@ Interactive form controls: |--------|-------------|-----------| | [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 | diff --git a/docs/widgets/layer.md b/docs/widgets/layer.md index bea8b047..17cac535 100644 --- a/docs/widgets/layer.md +++ b/docs/widgets/layer.md @@ -19,6 +19,7 @@ ui.layer({ |------|------|---------|-------------| | `id` | `string` | **required** | Layer identifier | | `zIndex` | `number` | insertion order | Higher values render on top (clamped for deterministic ordering) | +| `frameStyle` | `{ background?, foreground?, border? }` | - | Optional layer surface/frame colors (background fill, inherited text color, and border) | | `backdrop` | `"none" \| "dim" \| "opaque"` | `"none"` | Backdrop behind the layer | | `modal` | `boolean` | `false` | Block input to lower layers | | `closeOnEscape` | `boolean` | `true` | Close on Escape key | diff --git a/docs/widgets/layers.md b/docs/widgets/layers.md index 1ee8c964..edf216ea 100644 --- a/docs/widgets/layers.md +++ b/docs/widgets/layers.md @@ -16,6 +16,7 @@ ui.layers([ - Child order defines z-order: later children render on top. - Use [`Layer`](layer.md) for explicit z-index or custom backdrops. +- Overlay primitives (`Modal`, `Layer`, `Dropdown`, `CommandPalette`, `Toast`) share `frameStyle` for consistent background/foreground/border customization. - Layers enable deterministic hit testing and focus management across overlaps. - Backdrops (from [`Modal`](modal.md) and [`Layer`](layer.md)) render behind the active overlay(s) and stack in draw order. diff --git a/docs/widgets/modal.md b/docs/widgets/modal.md index 12918949..7d1a98dd 100644 --- a/docs/widgets/modal.md +++ b/docs/widgets/modal.md @@ -31,7 +31,8 @@ ui.layers([ | `actions` | `VNode[]` | `[]` | Action row (typically buttons) | | `width` | `number \| "auto"` | `~70%` | Preferred modal width | | `maxWidth` | `number` | - | Maximum width constraint | -| `backdrop` | `"none" \| "dim" \| "opaque"` | `"dim"` | Backdrop style | +| `frameStyle` | `{ background?, foreground?, border? }` | - | Optional modal frame/surface colors | +| `backdrop` | `"none" \| "dim" \| "opaque" \| { variant?, pattern?, foreground?, background? }` | `"dim"` | Backdrop preset or extended config | | `closeOnBackdrop` | `boolean` | `true` | Close when clicking backdrop | | `closeOnEscape` | `boolean` | `true` | Close on `Esc` | | `onClose` | `() => void` | - | Callback when modal requests close | @@ -68,6 +69,7 @@ ui.modal({ - Modals are rendered by conditionally including them in the tree (there is no `open` prop). - Render modals inside `ui.layers(...)` so they stack above base content. - Backdrops are rendered behind the modal. `"dim"` uses a light shade pattern; `"opaque"` clears the area behind the modal to the theme background color. +- Extended backdrop config uses object form: `variant` (preset), `pattern` (dim glyph), and optional `foreground`/`background` colors. - `width: "auto"` sizes to content/actions and is clamped by `maxWidth` and the viewport. ## Related diff --git a/docs/widgets/slider.md b/docs/widgets/slider.md new file mode 100644 index 00000000..c15ec37f --- /dev/null +++ b/docs/widgets/slider.md @@ -0,0 +1,51 @@ +# Slider + +A focusable range input for numeric values. + +## Usage + +```typescript +import { ui } from "@rezi-ui/core"; + +ui.slider({ + id: "volume", + value: state.volume, + min: 0, + max: 100, + step: 5, + onChange: (value) => app.update((s) => ({ ...s, volume: value })), +}); +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `id` | `string` | **required** | Unique identifier for focus and routing | +| `value` | `number` | **required** | Current value | +| `min` | `number` | `0` | Minimum value | +| `max` | `number` | `100` | Maximum value | +| `step` | `number` | `1` | Keyboard increment/decrement step | +| `width` | `number` | auto | Track width in cells | +| `label` | `string` | - | Optional label before the slider | +| `showValue` | `boolean` | `true` | Show numeric value text | +| `onChange` | `(value: number) => void` | - | Called when value changes | +| `disabled` | `boolean` | `false` | Disable focus and interaction | +| `readOnly` | `boolean` | `false` | Keep focusable, but block value changes | +| `style` | `TextStyle` | - | Optional style override | +| `key` | `string` | - | Reconciliation key | + +## Behavior + +- Focusable when enabled. +- **Left/Down** decreases by `step`. +- **Right/Up** increases by `step`. +- **PageDown/PageUp** changes by 10 steps. +- **Home/End** jumps to min/max. +- `readOnly` sliders still receive focus but do not emit `onChange`. + +## Related + +- [Input](input.md) - Free-form text input +- [Select](select.md) - Discrete option selection +- [Field](field.md) - Labels, hints, and error display diff --git a/docs/widgets/table.md b/docs/widgets/table.md index 75625dd9..91e461d2 100644 --- a/docs/widgets/table.md +++ b/docs/widgets/table.md @@ -8,7 +8,7 @@ Renders tabular data with column definitions, optional sorting, and row selectio ui.table({ id: "users", columns: [ - { key: "name", header: "Name", flex: 1, sortable: true }, + { key: "name", header: "Name", flex: 1, sortable: true, overflow: "middle" }, { key: "role", header: "Role", width: 12 }, ], data: state.users, @@ -16,6 +16,8 @@ ui.table({ selection: state.selection, selectionMode: "multi", onSelectionChange: (keys) => app.update((s) => ({ ...s, selection: keys })), + stripeStyle: { odd: { r: 34, g: 37, b: 45 } }, + borderStyle: { variant: "double", color: { r: 120, g: 130, b: 145 } }, }) ``` @@ -24,9 +26,11 @@ ui.table({ | Prop | Type | Default | Description | |---|---|---|---| | `id` | `string` | **required** | Unique identifier for focus and events | -| `columns` | `TableColumn[]` | **required** | Column definitions (`key`, `header`, width/flex, sortability, renderers) | +| `columns` | `TableColumn[]` | **required** | Column definitions (`key`, `header`, width/flex, sortability, renderers, `overflow`) | | `data` | `T[]` | **required** | Row data | | `getRowKey` | `(row: T, index: number) => string` | **required** | Stable key for each row | +| `rowHeight` | `number` | `1` | Row height in cells. Use positive values for predictable keyboard and mouse navigation. | +| `headerHeight` | `number` | `1` | Header row height in cells when `showHeader` is `true`. | | `selection` | `string[]` | `[]` | Currently selected row keys | | `selectionMode` | `"none" \| "single" \| "multi"` | `"none"` | Selection behavior | | `onSelectionChange` | `(keys: string[]) => void` | - | Called when selection changes | @@ -38,8 +42,10 @@ ui.table({ | `virtualized` | `boolean` | `true` | Enable windowed rendering for large datasets | | `overscan` | `number` | `3` | Extra rows rendered outside viewport | | `showHeader` | `boolean` | `true` | Show/hide header row | -| `stripedRows` | `boolean` | `false` | Alternate row background styling | -| `border` | `"none" \| "single"` | `"none"` | Optional border rendering | +| `stripedRows` | `boolean` | `false` | Legacy stripe toggle (kept for compatibility) | +| `stripeStyle` | `{ odd?, even? }` | - | Stripe background colors. Providing this enables stripes even when `stripedRows` is `false`. | +| `border` | `"none" \| "single"` | `"none"` | Legacy border toggle (kept for compatibility) | +| `borderStyle` | `{ variant?, color? }` | - | Border glyph variant (`single`, `double`, `rounded`, `heavy`, `dashed`, `heavy-dashed`) and optional border color | ## Examples @@ -73,6 +79,9 @@ ui.table({ - Tables can be virtualized; prefer virtualization for large datasets. - Selection is tracked by row keys. Provide a stable `getRowKey`. +- `headerHeight` is ignored when `showHeader` is `false`. +- Column `overflow` defaults to `"ellipsis"` and supports `"clip"` and `"middle"` per column. +- `borderStyle` is applied only when `border !== "none"` to preserve legacy behavior. ## Related diff --git a/docs/widgets/toast.md b/docs/widgets/toast.md index e0927b79..cddb2411 100644 --- a/docs/widgets/toast.md +++ b/docs/widgets/toast.md @@ -21,6 +21,7 @@ ui.toastContainer({ | `toasts` | `Toast[]` | **required** | Active toasts | | `position` | `ToastPosition` | `"bottom-right"` | Container position | | `maxVisible` | `number` | `5` | Max visible toasts | +| `frameStyle` | `{ background?, foreground?, border? }` | - | Optional frame/surface colors for toast background, message text, and border | | `onDismiss` | `(id) => void` | **required** | Dismiss callback | ## Mouse Behavior diff --git a/packages/core/package.json b/packages/core/package.json index 06c0faec..9cf4003c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -13,6 +13,10 @@ "url": "https://github.com/RtlZeroMemory/Rezi/issues" }, "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json --pretty false", + "test": "node --test --test-concurrency=1 $(find dist -type f -path '*/__tests__/*.test.js' | LC_ALL=C sort)" + }, "types": "./dist/index.d.ts", "exports": { ".": { diff --git a/packages/core/src/app/__tests__/widgetRenderer.integration.test.ts b/packages/core/src/app/__tests__/widgetRenderer.integration.test.ts index 9eff4db2..0e01aa89 100644 --- a/packages/core/src/app/__tests__/widgetRenderer.integration.test.ts +++ b/packages/core/src/app/__tests__/widgetRenderer.integration.test.ts @@ -231,6 +231,53 @@ describe("WidgetRenderer integration battery", () => { assert.equal(renderer.getFocusedId(), "z3"); }); + test("focusZone grid navigation moves by columns deterministically", () => { + const backend = createNoopBackend(); + const renderer = new WidgetRenderer({ + backend, + requestRender: () => {}, + }); + + const vnode = ui.column({}, [ + ui.focusZone({ id: "grid", navigation: "grid", columns: 3, wrapAround: false }, [ + ui.button({ id: "g1", label: "G1" }), + ui.button({ id: "g2", label: "G2" }), + ui.button({ id: "g3", label: "G3" }), + ui.button({ id: "g4", label: "G4" }), + ui.button({ id: "g5", label: "G5" }), + ui.button({ id: "g6", label: "G6" }), + ]), + ]); + + const res = renderer.submitFrame( + () => vnode, + undefined, + { cols: 60, rows: 10 }, + defaultTheme, + noRenderHooks(), + ); + assert.ok(res.ok); + assert.equal(renderer.getFocusedId(), null); + + renderer.routeEngineEvent(keyEvent(3 /* TAB */)); + assert.equal(renderer.getFocusedId(), "g1"); + + renderer.routeEngineEvent(keyEvent(21 /* DOWN */)); + assert.equal(renderer.getFocusedId(), "g4"); + + renderer.routeEngineEvent(keyEvent(21 /* DOWN */)); + assert.equal(renderer.getFocusedId(), "g4"); + + renderer.routeEngineEvent(keyEvent(23 /* RIGHT */)); + assert.equal(renderer.getFocusedId(), "g5"); + + renderer.routeEngineEvent(keyEvent(20 /* UP */)); + assert.equal(renderer.getFocusedId(), "g2"); + + renderer.routeEngineEvent(keyEvent(22 /* LEFT */)); + assert.equal(renderer.getFocusedId(), "g1"); + }); + test("focusZone onEnter/onExit fire on zone transitions", () => { const backend = createNoopBackend(); const renderer = new WidgetRenderer({ @@ -468,6 +515,68 @@ describe("WidgetRenderer integration battery", () => { assert.deepEqual(activated, ["t0"]); }); + test("slider routing clamps values and respects disabled/readOnly focus semantics", () => { + const backend = createNoopBackend(); + const renderer = new WidgetRenderer({ + backend, + requestRender: () => {}, + }); + + const changes: number[] = []; + + const vnode = ui.column({}, [ + ui.slider({ + id: "s-enabled", + value: 9, + min: 0, + max: 10, + step: 3, + onChange: (value) => changes.push(value), + }), + ui.slider({ + id: "s-disabled", + value: 4, + min: 0, + max: 10, + disabled: true, + onChange: (value) => changes.push(value), + }), + ui.slider({ + id: "s-readonly", + value: 4, + min: 0, + max: 10, + readOnly: true, + onChange: (value) => changes.push(value), + }), + ]); + + const res = renderer.submitFrame( + () => vnode, + undefined, + { cols: 50, rows: 10 }, + defaultTheme, + noRenderHooks(), + ); + assert.ok(res.ok); + assert.equal(renderer.getFocusedId(), null); + + renderer.routeEngineEvent(keyEvent(3 /* TAB */)); + assert.equal(renderer.getFocusedId(), "s-enabled"); + + renderer.routeEngineEvent(keyEvent(14 /* PAGE_UP */)); + renderer.routeEngineEvent(keyEvent(12 /* HOME */)); + assert.deepEqual(changes, [10, 0]); + + renderer.routeEngineEvent(keyEvent(3 /* TAB */)); + assert.equal(renderer.getFocusedId(), "s-readonly"); + renderer.routeEngineEvent(keyEvent(23 /* RIGHT */)); + assert.deepEqual(changes, [10, 0]); + + renderer.routeEngineEvent(keyEvent(3 /* TAB */)); + assert.equal(renderer.getFocusedId(), "s-enabled"); + }); + test("dropdown mouse click selects item and closes", () => { const backend = createNoopBackend(); const renderer = new WidgetRenderer({ diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 1353ab9c..b94ca535 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -102,6 +102,7 @@ import type { Theme } from "../theme/theme.js"; import { deleteRange, insertText } from "../widgets/codeEditor.js"; import { getHunkScrollPosition, navigateHunk } from "../widgets/diffViewer.js"; import { applyFilters } from "../widgets/logsConsole.js"; +import { adjustSliderValue, normalizeSliderState } from "../widgets/slider.js"; import { computePanelCellSizes, handleDividerDrag, @@ -124,6 +125,7 @@ import type { LogsConsoleProps, RadioGroupProps, SelectProps, + SliderProps, SplitDirection, SplitPaneProps, TableProps, @@ -285,6 +287,7 @@ function isRoutingRelevantKind(kind: WidgetKind): boolean { switch (kind) { case "button": case "input": + case "slider": case "focusZone": case "focusTrap": case "virtualList": @@ -321,6 +324,7 @@ function isDamageGranularityKind(kind: WidgetKind): boolean { case "spacer": case "button": case "input": + case "slider": case "select": case "checkbox": case "radioGroup": @@ -427,6 +431,7 @@ export class WidgetRenderer { private readonly tableById = new Map>(); private readonly treeById = new Map>(); private readonly dropdownById = new Map(); + private readonly sliderById = new Map(); private readonly selectById = new Map(); private readonly checkboxById = new Map(); private readonly radioGroupById = new Map(); @@ -1576,6 +1581,41 @@ export class WidgetRenderer { } } + // Slider routing (arrows, page keys, home/end) + const slider = this.sliderById.get(focusedId); + if (slider && slider.disabled !== true) { + const adjustment = + event.key === ZR_KEY_LEFT || event.key === ZR_KEY_DOWN + ? "decrease" + : event.key === ZR_KEY_RIGHT || event.key === ZR_KEY_UP + ? "increase" + : event.key === ZR_KEY_PAGE_DOWN + ? "decreasePage" + : event.key === ZR_KEY_PAGE_UP + ? "increasePage" + : event.key === ZR_KEY_HOME + ? "toMin" + : event.key === ZR_KEY_END + ? "toMax" + : null; + + if (adjustment !== null) { + if (slider.readOnly === true || !slider.onChange) return ROUTE_NO_RENDER; + const normalized = normalizeSliderState({ + value: slider.value, + min: slider.min, + max: slider.max, + step: slider.step, + }); + const nextValue = adjustSliderValue(normalized.value, normalized, adjustment); + if (nextValue !== normalized.value) { + slider.onChange(nextValue); + return ROUTE_RENDER; + } + return ROUTE_NO_RENDER; + } + } + // Select routing (simple: arrow keys cycle; Enter/Space cycles) const select = this.selectById.get(focusedId); if (select && select.disabled !== true && select.options.length > 0) { @@ -2795,6 +2835,7 @@ export class WidgetRenderer { this.tableById.clear(); this.treeById.clear(); this.dropdownById.clear(); + this.sliderById.clear(); this.selectById.clear(); this.checkboxById.clear(); this.radioGroupById.clear(); @@ -2966,6 +3007,10 @@ export class WidgetRenderer { } break; } + case "slider": { + this.sliderById.set((v.props as SliderProps).id, v.props as SliderProps); + break; + } case "select": { this.selectById.set((v.props as SelectProps).id, v.props as SelectProps); break; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f1c637ca..35e368d9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -137,6 +137,7 @@ export type { RichTextSpan, SelectOption, SelectProps, + SliderProps, SkeletonProps, SkeletonVariant, SpacerProps, @@ -147,8 +148,12 @@ export type { StatusType, StackProps, TagProps, + TableBorderStyle, + TableBorderVariant, TableColumn, + TableColumnOverflow, TableProps, + TableStripeStyle, TextProps, TextVariant, TreeProps, diff --git a/packages/core/src/layout/__tests__/layout.edgecases.test.ts b/packages/core/src/layout/__tests__/layout.edgecases.test.ts index a8571ec7..5b580f6a 100644 --- a/packages/core/src/layout/__tests__/layout.edgecases.test.ts +++ b/packages/core/src/layout/__tests__/layout.edgecases.test.ts @@ -23,6 +23,10 @@ describe("layout edge cases", () => { kind: "checkbox", props: { id: "checkbox-0", checked: false, label: "Check" }, }, + { + kind: "slider", + props: { id: "slider-0", value: 5, min: 0, max: 10 }, + }, { kind: "radioGroup", props: { @@ -49,7 +53,12 @@ describe("layout edge cases", () => { const tree = mustLayout(vnode, 12, 0); assert.equal(tree.rect.h, 0, `${vnode.kind} should clamp to 0 height`); - if (vnode.kind === "select" || vnode.kind === "checkbox" || vnode.kind === "radioGroup") { + if ( + vnode.kind === "select" || + vnode.kind === "checkbox" || + vnode.kind === "slider" || + vnode.kind === "radioGroup" + ) { const hit = hitTestFocusable(vnode, tree, 0, 0); assert.equal(hit, null, `${vnode.kind} with zero height should not be hit-testable`); } diff --git a/packages/core/src/layout/engine/layoutEngine.ts b/packages/core/src/layout/engine/layoutEngine.ts index 58d4f112..920e46f1 100644 --- a/packages/core/src/layout/engine/layoutEngine.ts +++ b/packages/core/src/layout/engine/layoutEngine.ts @@ -89,6 +89,7 @@ function measureNode(vnode: VNode, maxW: number, maxH: number, axis: Axis): Layo case "text": case "button": case "input": + case "slider": case "spacer": case "divider": case "icon": @@ -188,6 +189,16 @@ function measureNode(vnode: VNode, maxW: number, maxH: number, axis: Axis): Layo computed = measureOverlays(vnode, maxW, maxH, axis, measureNode); break; } + default: { + computed = { + ok: false, + fatal: { + code: "ZRUI_INVALID_PROPS", + detail: "measure: unexpected vnode kind", + }, + }; + break; + } } if (cache) { @@ -257,6 +268,7 @@ function layoutNode( case "text": case "button": case "input": + case "slider": case "spacer": case "divider": case "icon": @@ -349,6 +361,15 @@ function layoutNode( case "toastContainer": { return layoutOverlays(vnode, x, y, maxW, maxH, rectW, rectH, axis, measureNode, layoutNode); } + default: { + return { + ok: false, + fatal: { + code: "ZRUI_INVALID_PROPS", + detail: "layout: unexpected vnode kind", + }, + }; + } } } diff --git a/packages/core/src/layout/hitTest.ts b/packages/core/src/layout/hitTest.ts index 1d1e96d4..dbe55c9d 100644 --- a/packages/core/src/layout/hitTest.ts +++ b/packages/core/src/layout/hitTest.ts @@ -28,6 +28,7 @@ function isFocusable(v: VNode): string | null { case "virtualList": case "table": case "tree": + case "slider": case "select": case "checkbox": case "radioGroup": @@ -50,6 +51,7 @@ function isFocusable(v: VNode): string | null { if ( v.kind === "button" || v.kind === "input" || + v.kind === "slider" || v.kind === "select" || v.kind === "checkbox" || v.kind === "radioGroup" diff --git a/packages/core/src/layout/kinds/leaf.ts b/packages/core/src/layout/kinds/leaf.ts index 6fefdf7b..7d10ca46 100644 --- a/packages/core/src/layout/kinds/leaf.ts +++ b/packages/core/src/layout/kinds/leaf.ts @@ -1,4 +1,9 @@ import type { VNode } from "../../index.js"; +import { + DEFAULT_SLIDER_TRACK_WIDTH, + formatSliderValue, + normalizeSliderState, +} from "../../widgets/slider.js"; import { layoutLeaf } from "../engine/layoutTree.js"; import { ok } from "../engine/result.js"; import type { LayoutTree } from "../engine/types.js"; @@ -50,6 +55,34 @@ export function measureLeaf( const h = Math.min(maxH, 1); return ok({ w, h }); } + case "slider": { + const props = vnode.props as { + value?: number; + min?: number; + max?: number; + step?: number; + width?: number; + label?: string; + showValue?: boolean; + }; + const normalized = normalizeSliderState({ + value: props.value ?? Number.NaN, + min: props.min, + max: props.max, + step: props.step, + }); + const labelText = + typeof props.label === "string" && props.label.length > 0 ? `${props.label} ` : ""; + const showValue = props.showValue !== false; + const valueText = showValue ? ` ${formatSliderValue(normalized.value, normalized.step)}` : ""; + const explicitTrack = + typeof props.width === "number" && Number.isFinite(props.width) && props.width > 0 + ? Math.trunc(props.width) + : undefined; + const trackWidth = explicitTrack ?? DEFAULT_SLIDER_TRACK_WIDTH; + const intrinsicW = measureTextCells(labelText) + 2 + trackWidth + measureTextCells(valueText); + return ok({ w: Math.min(maxW, intrinsicW), h: Math.min(maxH, 1) }); + } case "spacer": { const propsRes = validateSpacerProps(vnode.props); if (!propsRes.ok) return propsRes; @@ -399,6 +432,14 @@ export function layoutLeafKind( children: Object.freeze([]), }); } + case "slider": { + // Slider is an interactive leaf widget. + return ok({ + vnode, + rect: { x, y, w: rectW, h: Math.min(rectH, 1) }, + children: Object.freeze([]), + }); + } case "checkbox": { // Checkbox is an interactive leaf widget. return ok({ diff --git a/packages/core/src/renderer/renderToDrawlist/renderTree.ts b/packages/core/src/renderer/renderToDrawlist/renderTree.ts index ae72c7fd..665bd8e2 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderTree.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderTree.ts @@ -132,6 +132,7 @@ export function renderTree( case "divider": case "button": case "input": + case "slider": case "select": case "checkbox": case "radioGroup": diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/basic.ts b/packages/core/src/renderer/renderToDrawlist/widgets/basic.ts index 7cc1ae36..6d51ef30 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/basic.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/basic.ts @@ -11,6 +11,11 @@ import type { RuntimeInstance } from "../../../runtime/commit.js"; import type { FocusState } from "../../../runtime/focus.js"; import type { Theme } from "../../../theme/theme.js"; import { resolveColor } from "../../../theme/theme.js"; +import { + DEFAULT_SLIDER_TRACK_WIDTH, + formatSliderValue, + normalizeSliderState, +} from "../../../widgets/slider.js"; import type { SelectOption } from "../../../widgets/types.js"; import { asTextStyle, getButtonLabelStyle } from "../../styles.js"; import { renderBoxBorder } from "../boxBorder.js"; @@ -83,6 +88,20 @@ function readNonNegativeInt(v: unknown): number | undefined { return Math.trunc(n); } +function readTerminalCursorMeta( + props: Readonly<{ + internal_terminalCursorFocus?: unknown; + internal_terminalCursorPosition?: unknown; + terminalCursorFocus?: unknown; + terminalCursorPosition?: unknown; + }>, +): Readonly<{ focused: boolean; position?: number }> { + const focused = (props.internal_terminalCursorFocus ?? props.terminalCursorFocus) === true; + const rawPosition = props.internal_terminalCursorPosition ?? props.terminalCursorPosition; + const position = readNonNegativeInt(rawPosition); + return { focused, ...(position === undefined ? {} : { position }) }; +} + function readPositiveInt(v: unknown): number | undefined { const n = readNonNegativeInt(v); if (n === undefined || n <= 0) return undefined; @@ -395,6 +414,10 @@ export function renderBasicWidget( variant?: unknown; textOverflow?: unknown; maxWidth?: unknown; + internal_terminalCursorFocus?: unknown; + internal_terminalCursorPosition?: unknown; + terminalCursorFocus?: unknown; + terminalCursorPosition?: unknown; }; const variantStyle = textVariantToStyle(props.variant); const ownStyle = asTextStyle(props.style); @@ -408,6 +431,9 @@ export function renderBasicWidget( if (overflowW <= 0) break; const text = vnode.text; + const cursorMeta = readTerminalCursorMeta(props); + const cursorOffset = Math.min(text.length, Math.max(0, cursorMeta.position ?? text.length)); + const cursorX = Math.min(overflowW, measureTextCells(text.slice(0, cursorOffset))); // Avoid measuring in the common ASCII case. const fits = @@ -415,6 +441,14 @@ export function renderBasicWidget( if (fits) { builder.drawText(rect.x, rect.y, text, style); + if (cursorInfo && cursorMeta.focused) { + resolvedCursor = { + x: rect.x + cursorX, + y: rect.y, + shape: cursorInfo.shape, + blink: cursorInfo.blink, + }; + } break; } @@ -439,6 +473,14 @@ export function renderBasicWidget( } else { builder.drawText(rect.x, rect.y, displayText, style); } + if (cursorInfo && cursorMeta.focused) { + resolvedCursor = { + x: rect.x + cursorX, + y: rect.y, + shape: cursorInfo.shape, + blink: cursorInfo.blink, + }; + } break; } case "divider": { @@ -570,6 +612,95 @@ export function renderBasicWidget( } break; } + case "slider": { + if (!isVisibleRect(rect)) break; + const props = vnode.props as { + id?: unknown; + value?: unknown; + min?: unknown; + max?: unknown; + step?: unknown; + width?: unknown; + label?: unknown; + showValue?: unknown; + disabled?: unknown; + readOnly?: unknown; + style?: unknown; + }; + const id = readString(props.id); + const focused = id !== undefined && focusState.focusedId === id; + const disabled = props.disabled === true; + const readOnly = props.readOnly === true; + const label = readString(props.label) ?? ""; + const showValue = props.showValue !== false; + const value = readNumber(props.value) ?? Number.NaN; + const min = readNumber(props.min); + const max = readNumber(props.max); + const step = readNumber(props.step); + const normalized = normalizeSliderState({ value, min, max, step }); + + const ownStyle = asTextStyle(props.style); + const style = mergeTextStyle(parentStyle, ownStyle); + maybeFillOwnBackground(builder, rect, ownStyle, style); + + let stateStyle: { fg?: Theme["colors"][string]; underline?: true; bold?: true; dim?: true }; + if (disabled) { + stateStyle = { fg: theme.colors.muted }; + } else if (focused && readOnly) { + stateStyle = { underline: true, bold: true, dim: true }; + } else if (focused) { + stateStyle = { underline: true, bold: true }; + } else if (readOnly) { + stateStyle = { dim: true }; + } else { + stateStyle = {}; + } + const textStyle = mergeTextStyle(style, stateStyle); + + const labelText = label.length > 0 ? `${label} ` : ""; + const valueText = + showValue === true ? ` ${formatSliderValue(normalized.value, normalized.step)}` : ""; + const dynamicTrack = rect.w - measureTextCells(labelText) - measureTextCells(valueText) - 2; // '[' + ']' + const explicitTrack = readPositiveInt(props.width); + const trackCells = Math.max( + 1, + explicitTrack ?? Math.max(1, dynamicTrack > 0 ? dynamicTrack : DEFAULT_SLIDER_TRACK_WIDTH), + ); + const span = normalized.max - normalized.min; + const ratio = span <= 0 ? 0 : clamp01((normalized.value - normalized.min) / span); + const thumbIndex = + trackCells <= 1 + ? 0 + : Math.max(0, Math.min(trackCells - 1, Math.round(ratio * (trackCells - 1)))); + const fillCells = trackCells <= 1 ? 0 : thumbIndex; + const emptyCells = Math.max(0, trackCells - fillCells - 1); + const trackText = `${repeatCached("█", fillCells)}●${repeatCached("░", emptyCells)}`; + + const trackStyle = mergeTextStyle( + textStyle, + disabled + ? { fg: theme.colors.muted } + : readOnly + ? { fg: theme.colors.info, dim: true } + : { fg: theme.colors.primary, bold: true }, + ); + const valueStyle = mergeTextStyle( + textStyle, + !disabled && readOnly ? { fg: theme.colors.muted } : undefined, + ); + + const segments: StyledSegment[] = []; + if (labelText.length > 0) segments.push({ text: labelText, style: textStyle }); + segments.push({ text: "[", style: textStyle }); + segments.push({ text: trackText, style: trackStyle }); + segments.push({ text: "]", style: textStyle }); + if (valueText.length > 0) segments.push({ text: valueText, style: valueStyle }); + + builder.pushClip(rect.x, rect.y, rect.w, rect.h); + drawSegments(builder, rect.x, rect.y, rect.w, segments); + builder.popClip(); + break; + } case "spacer": break; case "field": { @@ -722,16 +853,24 @@ export function renderBasicWidget( } case "richText": { if (!isVisibleRect(rect)) break; - const props = vnode.props as { spans?: unknown }; + const props = vnode.props as { + spans?: unknown; + internal_terminalCursorFocus?: unknown; + internal_terminalCursorPosition?: unknown; + terminalCursorFocus?: unknown; + terminalCursorPosition?: unknown; + }; const spans = Array.isArray(props.spans) ? (props.spans as readonly { text?: unknown; style?: unknown }[]) : []; if (spans.length === 0) break; const segments: StyledSegment[] = []; + let combinedText = ""; for (const span of spans) { const text = readString(span.text) ?? ""; if (text.length === 0) continue; + combinedText += text; segments.push({ text, style: mergeTextStyle(parentStyle, asTextStyle(span.style)), @@ -742,6 +881,20 @@ export function renderBasicWidget( builder.pushClip(rect.x, rect.y, rect.w, rect.h); drawSegments(builder, rect.x, rect.y, rect.w, segments); builder.popClip(); + const cursorMeta = readTerminalCursorMeta(props); + if (cursorInfo && cursorMeta.focused) { + const cursorOffset = Math.min( + combinedText.length, + Math.max(0, cursorMeta.position ?? combinedText.length), + ); + const cursorX = Math.min(rect.w, measureTextCells(combinedText.slice(0, cursorOffset))); + resolvedCursor = { + x: rect.x + cursorX, + y: rect.y, + shape: cursorInfo.shape, + blink: cursorInfo.blink, + }; + } break; } case "badge": { diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts index 9888d9fb..218a076f 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts @@ -1,5 +1,9 @@ import type { DrawlistBuilderV1 } from "../../../index.js"; -import { measureTextCells, truncateWithEllipsis } from "../../../layout/textMeasure.js"; +import { + measureTextCells, + truncateMiddle, + truncateWithEllipsis, +} from "../../../layout/textMeasure.js"; import type { Rect } from "../../../layout/types.js"; import type { RuntimeInstance } from "../../../runtime/commit.js"; import type { FocusState } from "../../../runtime/focus.js"; @@ -49,25 +53,93 @@ function cachedSpaces(count: number): string { } type CellAlign = "left" | "center" | "right"; +type CellOverflow = "clip" | "ellipsis" | "middle"; +type TableBorderGlyph = "single" | "double" | "rounded" | "heavy" | "dashed" | "heavy-dashed"; function readCellAlign(raw: unknown): CellAlign { return raw === "center" || raw === "right" ? raw : "left"; } -function alignCellContent(text: string, width: number, align: CellAlign): string { - if (width <= 0) return ""; - const clipped = measureTextCells(text) > width ? truncateWithEllipsis(text, width) : text; +function readCellOverflow(raw: unknown): CellOverflow { + return raw === "clip" || raw === "middle" ? raw : "ellipsis"; +} + +function readTableBorderVariant(raw: unknown): TableBorderGlyph | undefined { + switch (raw) { + case "single": + case "double": + case "rounded": + case "heavy": + case "dashed": + case "heavy-dashed": + return raw; + default: + return undefined; + } +} + +type AlignedCellText = Readonly<{ + text: string; + xOffset: number; + clip: boolean; +}>; + +function alignCellContent( + text: string, + width: number, + align: CellAlign, + overflow: CellOverflow, +): AlignedCellText { + if (width <= 0) return { text: "", xOffset: 0, clip: false }; + const textWidth = measureTextCells(text); + if (overflow === "clip" && textWidth > width) { + const xOffset = + align === "right" + ? width - textWidth + : align === "center" + ? Math.floor((width - textWidth) / 2) + : 0; + return { text, xOffset, clip: true }; + } + const clipped = + textWidth > width + ? overflow === "middle" + ? truncateMiddle(text, width) + : truncateWithEllipsis(text, width) + : text; const contentWidth = measureTextCells(clipped); const pad = Math.max(0, width - contentWidth); if (align === "right") { - return `${cachedSpaces(pad)}${clipped}`; + return { text: `${cachedSpaces(pad)}${clipped}`, xOffset: 0, clip: false }; } if (align === "center") { const leftPad = Math.floor(pad / 2); const rightPad = pad - leftPad; - return `${cachedSpaces(leftPad)}${clipped}${cachedSpaces(rightPad)}`; + return { + text: `${cachedSpaces(leftPad)}${clipped}${cachedSpaces(rightPad)}`, + xOffset: 0, + clip: false, + }; } - return `${clipped}${cachedSpaces(pad)}`; + return { text: `${clipped}${cachedSpaces(pad)}`, xOffset: 0, clip: false }; +} + +function drawAlignedCellText( + builder: DrawlistBuilderV1, + x: number, + y: number, + w: number, + h: number, + cell: AlignedCellText, + style: ResolvedTextStyle, +): void { + if (cell.clip) { + builder.pushClip(x, y, w, h); + builder.drawText(x + cell.xOffset, y, cell.text, style); + builder.popClip(); + return; + } + builder.drawText(x + cell.xOffset, y, cell.text, style); } function clampScrollTop(scrollTop: number, totalHeight: number, viewportHeight: number): number { @@ -170,9 +242,15 @@ export function renderCollectionWidget( if (!isVisibleRect(rect)) break; const props = vnode.props as TableProps; - const border = props.border === "none" ? "none" : "single"; - if (border === "single") - renderBoxBorder(builder, rect, "single", undefined, "left", parentStyle); + const borderVariant = readTableBorderVariant(props.borderStyle?.variant); + const border = + props.border === "none" ? "none" : borderVariant === undefined ? "single" : borderVariant; + if (border !== "none") { + const borderStyle = props.borderStyle?.color + ? mergeTextStyle(parentStyle, { fg: props.borderStyle.color }) + : parentStyle; + renderBoxBorder(builder, rect, border, undefined, "left", borderStyle); + } const headerHeight = props.showHeader === false ? 0 : (props.headerHeight ?? 1); const rowHeight = props.rowHeight ?? 1; @@ -181,7 +259,9 @@ export function renderCollectionWidget( const selection = (props.selection ?? EMPTY_STRING_ARRAY) as readonly string[]; const selectionMode = props.selectionMode ?? "none"; const virtualized = props.virtualized !== false; - const stripedRows = props.stripedRows === true; + const stripedRows = props.stripedRows === true || props.stripeStyle !== undefined; + const stripeOddBg = props.stripeStyle?.odd ?? theme.colors.border; + const stripeEvenBg = props.stripeStyle?.even; const tableCache = tableRenderCacheById?.get(props.id); const cachedRowKeys = tableCache?.rowKeys; const cachedSelectionSet = tableCache?.selectionSet; @@ -225,7 +305,8 @@ export function renderCollectionWidget( : ""; const headerText = `${col.header ?? ""}${sortIndicator}`; const headerAlign = readCellAlign(col.align); - const cell = alignCellContent(headerText, w, headerAlign); + const overflow = readCellOverflow(col.overflow); + const cell = alignCellContent(headerText, w, headerAlign, overflow); const headerStyle0 = sortIndicator.length > 0 ? mergeTextStyle(parentStyle, { bold: true, fg: theme.colors.info }) @@ -237,7 +318,7 @@ export function renderCollectionWidget( const headerStyle = isHeaderFocused ? mergeTextStyle(headerStyle0, { inverse: true }) : headerStyle0; - builder.drawText(xCursor, innerY, cell, headerStyle); + drawAlignedCellText(builder, xCursor, innerY, w, headerHeight, cell, headerStyle); xCursor += w; } } @@ -281,7 +362,7 @@ export function renderCollectionWidget( if (yRow >= bodyY + bodyH) break; if (yRow + safeRowHeight <= bodyY) continue; - const rowStripeBg = stripedRows && (i & 1) === 1 ? theme.colors.border : undefined; + const rowStripeBg = stripedRows ? ((i & 1) === 1 ? stripeOddBg : stripeEvenBg) : undefined; const rowBg = isFocusedRow ? undefined : isSelected ? theme.colors.secondary : rowStripeBg; if (rowBg) { builder.fillRect(innerX, yRow, innerW, safeRowHeight, { bg: rowBg }); @@ -321,8 +402,17 @@ export function renderCollectionWidget( } else { const t0 = cellText ?? ""; const align = readCellAlign(col.align); - const cell = alignCellContent(t0, w, align); - builder.drawText(xCursor, yRow, cell, mergeTextStyle(parentStyle, style)); + const overflow = readCellOverflow(col.overflow); + const cell = alignCellContent(t0, w, align, overflow); + drawAlignedCellText( + builder, + xCursor, + yRow, + w, + safeRowHeight, + cell, + mergeTextStyle(parentStyle, style), + ); } xCursor += w; diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts index 5706cd66..d30ed105 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts @@ -12,6 +12,18 @@ import type { ResolvedTextStyle } from "../textStyle.js"; import { mergeTextStyle, shouldFillForStyleOverride } from "../textStyle.js"; type ClipRect = Readonly; +type OverlayFrameColors = Readonly<{ + foreground?: ResolvedTextStyle["fg"]; + background?: ResolvedTextStyle["bg"]; + border?: ResolvedTextStyle["fg"]; +}>; + +type ModalBackdropConfig = Readonly<{ + variant: "none" | "dim" | "opaque"; + pattern: string; + foreground?: ResolvedTextStyle["fg"]; + background?: ResolvedTextStyle["bg"]; +}>; function clipEquals(a: ClipRect | undefined, b: ClipRect): boolean { return a !== undefined && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; @@ -151,6 +163,108 @@ function resolveBoxShadowConfig( }); } +function readRgbChannel(raw: unknown): number | null { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return null; + } + const clamped = Math.max(0, Math.min(255, Math.trunc(raw))); + return clamped; +} + +function readRgbColor(raw: unknown): ResolvedTextStyle["fg"] | undefined { + if (typeof raw !== "object" || raw === null) { + return undefined; + } + const color = raw as { r?: unknown; g?: unknown; b?: unknown }; + const r = readRgbChannel(color.r); + const g = readRgbChannel(color.g); + const b = readRgbChannel(color.b); + if (r === null || g === null || b === null) { + return undefined; + } + return { r, g, b }; +} + +function readOverlayFrameColors(raw: unknown): OverlayFrameColors { + if (typeof raw !== "object" || raw === null) { + return {}; + } + const frame = raw as { + foreground?: unknown; + background?: unknown; + border?: unknown; + }; + const foreground = readRgbColor(frame.foreground); + const background = readRgbColor(frame.background); + const border = readRgbColor(frame.border); + return { + ...(foreground !== undefined ? { foreground } : {}), + ...(background !== undefined ? { background } : {}), + ...(border !== undefined ? { border } : {}), + }; +} + +function toOverlaySurfaceStyle( + frame: OverlayFrameColors, +): Readonly<{ fg?: ResolvedTextStyle["fg"]; bg?: ResolvedTextStyle["bg"] }> | undefined { + if (frame.foreground === undefined && frame.background === undefined) { + return undefined; + } + return { + ...(frame.foreground !== undefined ? { fg: frame.foreground } : {}), + ...(frame.background !== undefined ? { bg: frame.background } : {}), + }; +} + +function readBackdropVariant( + raw: unknown, + fallback: ModalBackdropConfig["variant"], +): ModalBackdropConfig["variant"] { + if (raw === "none" || raw === "dim" || raw === "opaque") { + return raw; + } + return fallback; +} + +function readBackdropPattern(raw: unknown, fallback: string): string { + if (typeof raw !== "string") { + return fallback; + } + const first = Array.from(raw)[0]; + if (first === undefined || first.length === 0) { + return fallback; + } + return first; +} + +function resolveModalBackdrop(raw: unknown): ModalBackdropConfig { + if (raw === "none" || raw === "dim" || raw === "opaque") { + return { variant: raw, pattern: "░" }; + } + + if (typeof raw !== "object" || raw === null) { + return { variant: "dim", pattern: "░" }; + } + + const config = raw as { + variant?: unknown; + style?: unknown; + pattern?: unknown; + foreground?: unknown; + background?: unknown; + fg?: unknown; + bg?: unknown; + }; + const foreground = readRgbColor(config.foreground ?? config.fg); + const background = readRgbColor(config.background ?? config.bg); + return { + variant: readBackdropVariant(config.variant ?? config.style, "dim"), + pattern: readBackdropPattern(config.pattern, "░"), + ...(foreground !== undefined ? { foreground } : {}), + ...(background !== undefined ? { background } : {}), + }; +} + export function renderContainerWidget( builder: DrawlistBuilderV1, rect: Rect, @@ -314,25 +428,39 @@ export function renderContainerWidget( } case "modal": { if (!isVisibleRect(rect)) break; - const props = vnode.props as { title?: unknown; backdrop?: unknown }; + const props = vnode.props as { title?: unknown; backdrop?: unknown; frameStyle?: unknown }; const title = typeof props.title === "string" ? props.title : undefined; - const backdrop = - props.backdrop === "none" ? "none" : props.backdrop === "opaque" ? "opaque" : "dim"; + const frame = readOverlayFrameColors(props.frameStyle); + const surfaceStyle = mergeTextStyle(parentStyle, toOverlaySurfaceStyle(frame)); + const borderStyle = + frame.border !== undefined + ? mergeTextStyle(surfaceStyle, { fg: frame.border }) + : surfaceStyle; + const backdrop = resolveModalBackdrop(props.backdrop); const fill = currentClip ?? { x: 0, y: 0, w: viewport.cols, h: viewport.rows }; - if (backdrop === "opaque") { - builder.fillRect(fill.x, fill.y, fill.w, fill.h, { bg: theme.colors.bg }); - } else if (backdrop === "dim") { + if (backdrop.variant === "opaque") { + builder.fillRect(fill.x, fill.y, fill.w, fill.h, { + bg: backdrop.background ?? theme.colors.bg, + }); + } else if (backdrop.variant === "dim") { if (fill.w > 0 && fill.h > 0) { - const line = "░".repeat(fill.w); - const style: ResolvedTextStyle = { fg: theme.colors.border, bg: theme.colors.bg }; + const line = backdrop.pattern.repeat(fill.w); + const style: ResolvedTextStyle = { + fg: backdrop.foreground ?? theme.colors.border, + bg: backdrop.background ?? theme.colors.bg, + }; for (let dy = 0; dy < fill.h; dy++) { builder.drawText(fill.x, fill.y + dy, line, style); } } } - renderBoxBorder(builder, rect, "single", title, "left", parentStyle); + if (frame.background !== undefined) { + builder.fillRect(rect.x, rect.y, rect.w, rect.h, surfaceStyle); + } + + renderBoxBorder(builder, rect, "single", title, "left", borderStyle); // Clip modal interior (exclude border) const cx = rect.x + 1; @@ -348,7 +476,7 @@ export function renderContainerWidget( pushChildrenWithLayout( node, layoutNode, - parentStyle, + surfaceStyle, nodeStack, styleStack, layoutStack, @@ -392,7 +520,9 @@ export function renderContainerWidget( } case "layer": { // Generic layer: transparent container for its content VNode. - const props = vnode.props as { backdrop?: unknown }; + const props = vnode.props as { backdrop?: unknown; frameStyle?: unknown }; + const frame = readOverlayFrameColors(props.frameStyle); + const layerStyle = mergeTextStyle(parentStyle, toOverlaySurfaceStyle(frame)); const backdrop = props.backdrop === "dim" || props.backdrop === "opaque" || props.backdrop === "none" ? props.backdrop @@ -411,10 +541,17 @@ export function renderContainerWidget( } } } + if (frame.background !== undefined) { + builder.fillRect(rect.x, rect.y, rect.w, rect.h, layerStyle); + } + if (frame.border !== undefined) { + const borderStyle = mergeTextStyle(layerStyle, { fg: frame.border }); + renderBoxBorder(builder, rect, "single", undefined, "left", borderStyle); + } pushChildrenWithLayout( node, layoutNode, - parentStyle, + layerStyle, nodeStack, styleStack, layoutStack, diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/overlays.ts b/packages/core/src/renderer/renderToDrawlist/widgets/overlays.ts index 34d90bcc..b6af28c2 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/overlays.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/overlays.ts @@ -39,10 +39,68 @@ type ToastPosition = | "bottom-center" | "bottom-right"; +type OverlayFrameColors = Readonly<{ + foreground?: ResolvedTextStyle["fg"]; + background?: ResolvedTextStyle["bg"]; + border?: ResolvedTextStyle["fg"]; +}>; + function readString(raw: unknown, fallback = ""): string { return typeof raw === "string" ? raw : fallback; } +function readRgbChannel(raw: unknown): number | null { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return null; + } + return Math.max(0, Math.min(255, Math.trunc(raw))); +} + +function readRgbColor(raw: unknown): ResolvedTextStyle["fg"] | undefined { + if (typeof raw !== "object" || raw === null) { + return undefined; + } + const color = raw as { r?: unknown; g?: unknown; b?: unknown }; + const r = readRgbChannel(color.r); + const g = readRgbChannel(color.g); + const b = readRgbChannel(color.b); + if (r === null || g === null || b === null) { + return undefined; + } + return { r, g, b }; +} + +function readOverlayFrameColors(raw: unknown): OverlayFrameColors { + if (typeof raw !== "object" || raw === null) { + return {}; + } + const frame = raw as { + foreground?: unknown; + background?: unknown; + border?: unknown; + }; + const foreground = readRgbColor(frame.foreground); + const background = readRgbColor(frame.background); + const border = readRgbColor(frame.border); + return { + ...(foreground !== undefined ? { foreground } : {}), + ...(background !== undefined ? { background } : {}), + ...(border !== undefined ? { border } : {}), + }; +} + +function toOverlaySurfaceStyle( + frame: OverlayFrameColors, +): Readonly<{ fg?: ResolvedTextStyle["fg"]; bg?: ResolvedTextStyle["bg"] }> | undefined { + if (frame.foreground === undefined && frame.background === undefined) { + return undefined; + } + return { + ...(frame.foreground !== undefined ? { fg: frame.foreground } : {}), + ...(frame.background !== undefined ? { bg: frame.background } : {}), + }; +} + function readNonNegativeInt(raw: unknown, fallback: number): number { if (typeof raw !== "number" || !Number.isFinite(raw)) { return fallback; @@ -124,6 +182,12 @@ export function renderOverlayWidget( const props = vnode.props as DropdownProps; const anchor = idRectIndex.get(props.anchorId) ?? null; if (!anchor) break; + const frame = readOverlayFrameColors(props.frameStyle); + const dropdownStyle = mergeTextStyle(parentStyle, toOverlaySurfaceStyle(frame)); + const borderStyle = + frame.border !== undefined + ? mergeTextStyle(dropdownStyle, { fg: frame.border }) + : dropdownStyle; const items = Array.isArray(props.items) ? props.items : []; const selectedIndex = dropdownSelectedIndexById?.get(props.id) ?? 0; @@ -157,8 +221,18 @@ export function renderOverlayWidget( const dropdownRect = pos.rect; if (!isVisibleRect(dropdownRect)) break; + if (frame.background !== undefined) { + builder.fillRect( + dropdownRect.x, + dropdownRect.y, + dropdownRect.w, + dropdownRect.h, + dropdownStyle, + ); + } + // Render dropdown border - renderBoxBorder(builder, dropdownRect, "single", undefined, "left", parentStyle); + renderBoxBorder(builder, dropdownRect, "single", undefined, "left", borderStyle); // Render items const cx = dropdownRect.x + 1; @@ -174,7 +248,7 @@ export function renderOverlayWidget( } if (item.divider) { // Render divider - builder.drawText(cx, cy, "\u2500".repeat(cw), parentStyle); + builder.drawText(cx, cy, "\u2500".repeat(cw), borderStyle); } else { const isSelected = index === selectedIndex; const disabled = item.disabled === true; @@ -188,17 +262,20 @@ export function renderOverlayWidget( } const style = disabled - ? mergeTextStyle(parentStyle, { fg: theme.colors.muted }) + ? mergeTextStyle(dropdownStyle, { fg: theme.colors.muted }) : isSelected - ? mergeTextStyle(parentStyle, { fg: theme.colors.bg, bold: true }) - : parentStyle; + ? mergeTextStyle(dropdownStyle, { + fg: frame.background ?? theme.colors.bg, + bold: true, + }) + : dropdownStyle; builder.drawText(cx, cy, truncateWithEllipsis(label, labelW > 0 ? labelW : cw), style); if (shortcutW > 0 && cw > shortcutW) { const shortcutX = cx + cw - shortcutW; if (shortcutX > cx) { const shortcutStyle = isSelected && !disabled - ? mergeTextStyle(parentStyle, { fg: theme.colors.info }) + ? mergeTextStyle(dropdownStyle, { fg: theme.colors.info }) : mergeTextStyle(style, { dim: true }); builder.drawText(shortcutX, cy, shortcut, shortcutStyle); } @@ -223,29 +300,34 @@ export function renderOverlayWidget( const internalLoading = paletteId.length > 0 ? (commandPaletteLoadingById?.get(paletteId) ?? false) : false; const loading = props.loading === true || internalLoading; + const frame = readOverlayFrameColors(props.frameStyle); // Color palette for command palette - const paletteBg = theme.colors.bg; - const paletteBorder = theme.colors.border; + const paletteBg = frame.background ?? theme.colors.bg; + const paletteBorder = frame.border ?? theme.colors.border; const paletteAccent = theme.colors.primary; - const paletteText = theme.colors.fg; + const paletteText = frame.foreground ?? theme.colors.fg; const paletteMuted = theme.colors.muted; const paletteSelectedBg = theme.colors.secondary; + const paletteStyle = mergeTextStyle(parentStyle, { + fg: paletteText, + bg: paletteBg, + }); + const paletteBorderStyle = mergeTextStyle(paletteStyle, { fg: paletteBorder }); + const paletteMutedStyle = mergeTextStyle(paletteStyle, { fg: paletteMuted }); + const paletteAccentStyle = mergeTextStyle(paletteStyle, { fg: paletteAccent }); // Draw background builder.fillRect(rect.x, rect.y, rect.w, rect.h, { bg: paletteBg }); - renderBoxBorder(builder, rect, "single", undefined, "left", { - ...parentStyle, - fg: paletteBorder, - }); + renderBoxBorder(builder, rect, "single", undefined, "left", paletteBorderStyle); // Draw search icon and input field const inputY = rect.y + 1; const placeholder = readString(props.placeholder, "Search commands..."); const query = readString(props.query); - builder.drawText(rect.x + 2, inputY, "◈", { fg: paletteAccent }); + builder.drawText(rect.x + 2, inputY, "◈", paletteAccentStyle); const displayText = query.length > 0 ? query : placeholder; - const textStyle = query.length > 0 ? { fg: paletteText } : { fg: paletteMuted }; + const textStyle = query.length > 0 ? paletteStyle : paletteMutedStyle; const inputW = clampNonNegative(rect.w - 6); builder.drawText(rect.x + 4, inputY, truncateWithEllipsis(displayText, inputW), textStyle); @@ -266,12 +348,12 @@ export function renderOverlayWidget( // Draw separator with accent highlight const separatorW = clampNonNegative(rect.w - 2); if (separatorW > 0) { - builder.drawText(rect.x + 1, rect.y + 2, "─".repeat(separatorW), { fg: paletteBorder }); + builder.drawText(rect.x + 1, rect.y + 2, "─".repeat(separatorW), paletteBorderStyle); } // Loading indicator if (loading && rect.w >= 5) { - builder.drawText(rect.x + rect.w - 5, inputY, "···", { fg: paletteAccent }); + builder.drawText(rect.x + rect.w - 5, inputY, "···", paletteAccentStyle); } // Items list - explicit bounds calculation with extra safety margin @@ -290,9 +372,12 @@ export function renderOverlayWidget( nodeStack.push(null); if (items.length === 0) { - builder.drawText(listX, listY, truncateWithEllipsis("No matching commands", listW), { - fg: paletteMuted, - }); + builder.drawText( + listX, + listY, + truncateWithEllipsis("No matching commands", listW), + paletteMutedStyle, + ); break; } @@ -321,10 +406,10 @@ export function renderOverlayWidget( const iconText = readString(item.icon); const icon = iconText.length > 0 ? `${iconText} ` : ""; const labelStyle = disabled - ? { fg: paletteMuted } + ? paletteMutedStyle : isSelected - ? { fg: theme.colors.bg, bold: true } - : { fg: paletteText }; + ? mergeTextStyle(paletteStyle, { fg: frame.background ?? theme.colors.bg, bold: true }) + : paletteStyle; const label = `${icon}${readString(item.label)}`; const truncatedLabel = truncateWithEllipsis(label, labelMaxWidth); builder.drawText(listX, y, truncatedLabel, labelStyle); @@ -334,7 +419,9 @@ export function renderOverlayWidget( const sw = measureTextCells(shortcut); const sx = listX + listW - sw; if (sx > listX + measureTextCells(truncatedLabel) + 1) { - const shortcutStyle = isSelected ? { fg: theme.colors.info } : { fg: paletteMuted }; + const shortcutStyle = isSelected + ? mergeTextStyle(paletteStyle, { fg: theme.colors.info }) + : paletteMutedStyle; builder.drawText(sx, y, shortcut, shortcutStyle); } } @@ -419,7 +506,10 @@ export function renderOverlayWidget( toasts?: unknown; position?: unknown; maxVisible?: unknown; + frameStyle?: unknown; }; + const frame = readOverlayFrameColors(props.frameStyle); + const toastBaseStyle = mergeTextStyle(parentStyle, toOverlaySurfaceStyle(frame)); const toasts = Array.isArray(props.toasts) ? props.toasts : EMPTY_TOASTS; const position = readToastPosition(props.position); const maxVisible = readNonNegativeInt(props.maxVisible, 5); @@ -446,32 +536,35 @@ export function renderOverlayWidget( : rect.y + rect.h - (i + 1) * TOAST_HEIGHT; const icon = TOAST_ICONS[type]; - const color = toastTypeToThemeColor(theme, type); + const color = frame.border ?? toastTypeToThemeColor(theme, type); // Toast background - builder.fillRect(rect.x, toastY, rect.w, TOAST_HEIGHT, { bg: theme.colors.bg }); + builder.fillRect(rect.x, toastY, rect.w, TOAST_HEIGHT, { + bg: frame.background ?? theme.colors.bg, + }); // Border + const borderStyle = mergeTextStyle(toastBaseStyle, { fg: color }); if (rect.w === 1) { - builder.drawText(rect.x, toastY, "┌", { fg: color }); - builder.drawText(rect.x, toastY + 1, "│", { fg: color }); - builder.drawText(rect.x, toastY + 2, "└", { fg: color }); + builder.drawText(rect.x, toastY, "┌", borderStyle); + builder.drawText(rect.x, toastY + 1, "│", borderStyle); + builder.drawText(rect.x, toastY + 2, "└", borderStyle); } else { const inner = rect.w > 2 ? "─".repeat(rect.w - 2) : ""; - builder.drawText(rect.x, toastY, `┌${inner}┐`, { fg: color }); - builder.drawText(rect.x, toastY + 1, "│", { fg: color }); - builder.drawText(rect.x + rect.w - 1, toastY + 1, "│", { fg: color }); - builder.drawText(rect.x, toastY + 2, `└${inner}┘`, { fg: color }); + builder.drawText(rect.x, toastY, `┌${inner}┐`, borderStyle); + builder.drawText(rect.x, toastY + 1, "│", borderStyle); + builder.drawText(rect.x + rect.w - 1, toastY + 1, "│", borderStyle); + builder.drawText(rect.x, toastY + 2, `└${inner}┘`, borderStyle); } // Icon and message - builder.drawText(rect.x + 2, toastY + 1, icon, { fg: color }); + builder.drawText(rect.x + 2, toastY + 1, icon, borderStyle); const messageMax = Math.max(0, rect.w - 6); builder.drawText( rect.x + 4, toastY + 1, truncateWithEllipsis(message, messageMax), - parentStyle, + toastBaseStyle, ); const action = @@ -488,7 +581,7 @@ export function renderOverlayWidget( ax, toastY + 1, truncateWithEllipsis(label, Math.max(0, rect.x + rect.w - 1 - ax)), - focused ? { fg: color, inverse: true } : { fg: color }, + focused ? mergeTextStyle(toastBaseStyle, { fg: color, inverse: true }) : borderStyle, ); } } diff --git a/packages/core/src/runtime/__tests__/widgetMeta.test.ts b/packages/core/src/runtime/__tests__/widgetMeta.test.ts index a1f2d181..4d06cfbe 100644 --- a/packages/core/src/runtime/__tests__/widgetMeta.test.ts +++ b/packages/core/src/runtime/__tests__/widgetMeta.test.ts @@ -80,6 +80,23 @@ test("widgetMeta: SplitPane/PanelGroup are not focusable but their children are assert.deepEqual(focusable, ["left", "right", "nested"]); }); +test("widgetMeta: slider participates in focusable/enabled metadata", () => { + const vnode = ui.column({}, [ + ui.slider({ id: "s1", value: 5, min: 0, max: 10 }), + ui.slider({ id: "s2", value: 5, min: 0, max: 10, disabled: true }), + ui.slider({ id: "s3", value: 5, min: 0, max: 10, readOnly: true }), + ]); + + const committed = commitTree(vnode); + const focusable = collectFocusableIds(committed); + assert.deepEqual(focusable, ["s1", "s3"]); + + const enabled = collectEnabledMap(committed); + assert.equal(enabled.get("s1"), true); + assert.equal(enabled.get("s2"), false); + assert.equal(enabled.get("s3"), true); +}); + test("widgetMeta: collectAllWidgetMetadata produces same results as individual collectors", () => { // Complex tree with buttons, inputs, zones, and traps const vnode = ui.column({}, [ diff --git a/packages/core/src/runtime/commit.ts b/packages/core/src/runtime/commit.ts index a8bc4cb0..e219e71a 100644 --- a/packages/core/src/runtime/commit.ts +++ b/packages/core/src/runtime/commit.ts @@ -377,6 +377,7 @@ function isInteractiveVNode(v: VNode): boolean { return ( v.kind === "button" || v.kind === "input" || + v.kind === "slider" || v.kind === "virtualList" || v.kind === "table" || v.kind === "tree" || diff --git a/packages/core/src/runtime/widgetMeta.ts b/packages/core/src/runtime/widgetMeta.ts index 2fa4012c..ba486107 100644 --- a/packages/core/src/runtime/widgetMeta.ts +++ b/packages/core/src/runtime/widgetMeta.ts @@ -6,7 +6,7 @@ * deterministic traversal-order collections of widget properties. * * Collections: - * - focusableIds: enabled Buttons + Inputs in traversal order + * - focusableIds: enabled focusable widgets in traversal order * - enabledMap: all interactive widgets mapped to enabled state * - pressableIds: Buttons that can produce "press" actions * - inputMetaById: Input widget metadata (value, cursor, etc.) @@ -20,11 +20,12 @@ import type { FocusZoneNavigation } from "../widgets/types.js"; import type { RuntimeInstance } from "./commit.js"; import type { InstanceId } from "./instance.js"; -/** Extract interactive widget ID (Button or Input with valid id prop). */ +/** Extract interactive widget ID from a node with a valid `id` prop. */ function readInteractiveId(v: RuntimeInstance["vnode"]): string | null { switch (v.kind) { case "button": case "input": + case "slider": case "virtualList": case "table": case "tree": @@ -56,6 +57,7 @@ function isFocusableInteractive(v: RuntimeInstance["vnode"]): boolean { switch (v.kind) { case "button": case "input": + case "slider": case "virtualList": case "table": case "tree": @@ -84,6 +86,7 @@ function isEnabledInteractive(v: RuntimeInstance["vnode"]): string | null { if ( v.kind === "button" || v.kind === "input" || + v.kind === "slider" || v.kind === "select" || v.kind === "checkbox" || v.kind === "radioGroup" @@ -106,7 +109,7 @@ function isEnabledInteractive(v: RuntimeInstance["vnode"]): string | null { * * - Order: depth-first preorder * - Children: left-to-right - * - Focusable set: enabled Buttons + Inputs + * - Focusable set: enabled focusable interactive widgets */ export function collectFocusableIds(tree: RuntimeInstance): readonly string[] { const out: string[] = []; @@ -131,7 +134,7 @@ export function collectFocusableIds(tree: RuntimeInstance): readonly string[] { /** * Collect a deterministic enabled map (interactive id -> enabled) from a committed runtime tree. * - * Interactive widgets (Buttons + Inputs) are always included when their `id` is a non-empty string; + * Interactive widgets are always included when their `id` is a non-empty string; * enabled is `true` iff `disabled !== true`. */ export function collectEnabledMap(tree: RuntimeInstance): ReadonlyMap { @@ -148,6 +151,7 @@ export function collectEnabledMap(tree: RuntimeInstance): ReadonlyMap { ); }); + test("slider renders track and clamps displayed value to range", () => { + const strings = parseInternedStrings( + renderBytes( + ui.slider({ + id: "slider", + label: "Volume", + value: 999, + min: 0, + max: 10, + step: 2, + showValue: true, + width: 8, + }), + ), + ); + assert.equal( + strings.some((s) => s.includes("Volume")), + true, + ); + assert.equal( + strings.some((s) => s.includes("10")), + true, + ); + assert.equal( + strings.some((s) => s.includes("●")), + true, + ); + }); + test("skeleton emits placeholder glyphs", () => { const strings = parseInternedStrings(renderBytes(ui.skeleton(8, { variant: "rect" }))); assert.equal( diff --git a/packages/core/src/widgets/__tests__/collections.test.ts b/packages/core/src/widgets/__tests__/collections.test.ts index 593541b3..9623a6f4 100644 --- a/packages/core/src/widgets/__tests__/collections.test.ts +++ b/packages/core/src/widgets/__tests__/collections.test.ts @@ -45,7 +45,7 @@ describe("collections", () => { test("ui.table creates table VNode and preserves optional props", () => { const vnode = ui.table({ id: "table", - columns: [{ key: "name", header: "Name", sortable: true }], + columns: [{ key: "name", header: "Name", sortable: true, overflow: "middle" }], data: [{ name: "A" }], getRowKey: (row) => row.name, rowHeight: 1, @@ -57,8 +57,10 @@ describe("collections", () => { virtualized: true, overscan: 5, stripedRows: true, + stripeStyle: { odd: { r: 1, g: 2, b: 3 }, even: { r: 4, g: 5, b: 6 } }, showHeader: true, border: "single", + borderStyle: { variant: "double", color: { r: 7, g: 8, b: 9 } }, onSelectionChange: () => undefined, onSort: () => undefined, onRowPress: () => undefined, @@ -71,6 +73,12 @@ describe("collections", () => { assert.equal(vnode.props.data.length, 1); assert.equal(vnode.props.selectionMode, "multi"); assert.equal(vnode.props.border, "single"); + assert.equal(vnode.props.columns[0]?.overflow, "middle"); + assert.deepEqual(vnode.props.stripeStyle, { + odd: { r: 1, g: 2, b: 3 }, + even: { r: 4, g: 5, b: 6 }, + }); + assert.deepEqual(vnode.props.borderStyle, { variant: "double", color: { r: 7, g: 8, b: 9 } }); }); test("ui.tree creates tree VNode with optional tree features", () => { diff --git a/packages/core/src/widgets/__tests__/containers.test.ts b/packages/core/src/widgets/__tests__/containers.test.ts index b4a8f362..71d1b8bb 100644 --- a/packages/core/src/widgets/__tests__/containers.test.ts +++ b/packages/core/src/widgets/__tests__/containers.test.ts @@ -69,6 +69,53 @@ describe("container widgets - VNode construction", () => { assert.equal(layers.children.length, 3); }); + test("modal and layer preserve frameStyle and extended modal backdrop config", () => { + const modal = ui.modal({ + id: "styled-modal", + title: "Styled", + content: ui.text("Body"), + frameStyle: { + background: { r: 20, g: 22, b: 24 }, + foreground: { r: 220, g: 222, b: 224 }, + border: { r: 120, g: 122, b: 124 }, + }, + backdrop: { + variant: "dim", + pattern: "#", + foreground: { r: 60, g: 70, b: 80 }, + background: { r: 4, g: 5, b: 6 }, + }, + }); + assert.equal(modal.kind, "modal"); + assert.deepEqual(modal.props.backdrop, { + variant: "dim", + pattern: "#", + foreground: { r: 60, g: 70, b: 80 }, + background: { r: 4, g: 5, b: 6 }, + }); + assert.deepEqual(modal.props.frameStyle, { + background: { r: 20, g: 22, b: 24 }, + foreground: { r: 220, g: 222, b: 224 }, + border: { r: 120, g: 122, b: 124 }, + }); + + const layer = ui.layer({ + id: "styled-layer", + content: ui.text("overlay"), + frameStyle: { + background: { r: 10, g: 11, b: 12 }, + foreground: { r: 230, g: 231, b: 232 }, + border: { r: 90, g: 91, b: 92 }, + }, + }); + assert.equal(layer.kind, "layer"); + assert.deepEqual(layer.props.frameStyle, { + background: { r: 10, g: 11, b: 12 }, + foreground: { r: 230, g: 231, b: 232 }, + border: { r: 90, g: 91, b: 92 }, + }); + }); + test("splitPane, panelGroup, and resizablePanel create expected VNodes", () => { const left = ui.text("Left"); const right = ui.text("Right"); diff --git a/packages/core/src/widgets/__tests__/overlays.test.ts b/packages/core/src/widgets/__tests__/overlays.test.ts index d4f31767..69594d8e 100644 --- a/packages/core/src/widgets/__tests__/overlays.test.ts +++ b/packages/core/src/widgets/__tests__/overlays.test.ts @@ -28,6 +28,23 @@ describe("overlay widgets - VNode construction", () => { }); }); + test("dropdown preserves frameStyle colors", () => { + const frameStyle = { + background: { r: 12, g: 18, b: 24 }, + foreground: { r: 200, g: 210, b: 220 }, + border: { r: 80, g: 90, b: 100 }, + } as const; + const vnode = ui.dropdown({ + id: "styled-menu", + anchorId: "anchor", + items: [{ id: "one", label: "One" }], + frameStyle, + }); + + assert.equal(vnode.kind, "dropdown"); + assert.deepEqual(vnode.props.frameStyle, frameStyle); + }); + test("commandPalette creates VNode and preserves query/open state", () => { const source = { id: "cmd", @@ -60,6 +77,28 @@ describe("overlay widgets - VNode construction", () => { assert.equal(vnode.props.maxVisible, 15); }); + test("commandPalette preserves frameStyle colors", () => { + const frameStyle = { + background: { r: 11, g: 12, b: 13 }, + foreground: { r: 210, g: 211, b: 212 }, + border: { r: 100, g: 101, b: 102 }, + } as const; + const vnode = ui.commandPalette({ + id: "palette-styled", + open: true, + query: "run", + sources: [{ id: "cmd", name: "Commands", getItems: () => [] }], + selectedIndex: 0, + frameStyle, + onQueryChange: () => undefined, + onSelect: () => undefined, + onClose: () => undefined, + }); + + assert.equal(vnode.kind, "commandPalette"); + assert.deepEqual(vnode.props.frameStyle, frameStyle); + }); + test("toolApprovalDialog creates VNode and handles optional focused action", () => { const vnode = ui.toolApprovalDialog({ id: "approval", @@ -105,4 +144,20 @@ describe("overlay widgets - VNode construction", () => { assert.equal(vnode.props.position, "top-left"); assert.equal(vnode.props.maxVisible, 0); }); + + test("toastContainer preserves frameStyle colors", () => { + const frameStyle = { + background: { r: 5, g: 6, b: 7 }, + foreground: { r: 230, g: 231, b: 232 }, + border: { r: 140, g: 141, b: 142 }, + } as const; + const vnode = ui.toastContainer({ + toasts: [], + onDismiss: () => undefined, + frameStyle, + }); + + assert.equal(vnode.kind, "toastContainer"); + assert.deepEqual(vnode.props.frameStyle, frameStyle); + }); }); diff --git a/packages/core/src/widgets/__tests__/overlays.typecheck.ts b/packages/core/src/widgets/__tests__/overlays.typecheck.ts new file mode 100644 index 00000000..998314d2 --- /dev/null +++ b/packages/core/src/widgets/__tests__/overlays.typecheck.ts @@ -0,0 +1,54 @@ +import type { + CommandPaletteProps, + DropdownProps, + LayerProps, + ModalProps, + ToastContainerProps, +} from "../types.js"; + +const modalBackdropPreset: ModalProps["backdrop"] = "dim"; +const modalBackdropObject: ModalProps["backdrop"] = { + variant: "opaque", + pattern: "#", + foreground: { r: 10, g: 20, b: 30 }, + background: { r: 1, g: 2, b: 3 }, +}; + +// @ts-expect-error invalid backdrop variant +const modalBackdropInvalid: ModalProps["backdrop"] = { variant: "blur" }; + +const dropdownFrame: DropdownProps["frameStyle"] = { + background: { r: 1, g: 2, b: 3 }, + foreground: { r: 4, g: 5, b: 6 }, + border: { r: 7, g: 8, b: 9 }, +}; + +// @ts-expect-error missing b component +const dropdownFrameInvalid: DropdownProps["frameStyle"] = { border: { r: 1, g: 2 } }; + +const layerFrame: LayerProps["frameStyle"] = { + background: { r: 9, g: 9, b: 9 }, + foreground: { r: 200, g: 200, b: 200 }, + border: { r: 120, g: 120, b: 120 }, +}; + +const commandPaletteFrame: CommandPaletteProps["frameStyle"] = { + background: { r: 12, g: 13, b: 14 }, + foreground: { r: 220, g: 221, b: 222 }, + border: { r: 100, g: 110, b: 120 }, +}; + +const toastFrame: ToastContainerProps["frameStyle"] = { + background: { r: 15, g: 16, b: 17 }, + foreground: { r: 230, g: 231, b: 232 }, + border: { r: 111, g: 112, b: 113 }, +}; + +void modalBackdropPreset; +void modalBackdropObject; +void modalBackdropInvalid; +void dropdownFrame; +void dropdownFrameInvalid; +void layerFrame; +void commandPaletteFrame; +void toastFrame; diff --git a/packages/core/src/widgets/__tests__/renderer.regressions.test.ts b/packages/core/src/widgets/__tests__/renderer.regressions.test.ts index 84597879..0c82de3b 100644 --- a/packages/core/src/widgets/__tests__/renderer.regressions.test.ts +++ b/packages/core/src/widgets/__tests__/renderer.regressions.test.ts @@ -65,6 +65,48 @@ function parseInternedStrings(bytes: Uint8Array): readonly string[] { return Object.freeze(out); } +type CommandStyle = Readonly<{ + opcode: number; + fg: number; + bg: number; + attrs: number; +}>; + +function parseCommandStyles(bytes: Uint8Array): readonly CommandStyle[] { + const cmdOffset = u32(bytes, 16); + const cmdBytes = u32(bytes, 20); + const end = cmdOffset + cmdBytes; + + const out: CommandStyle[] = []; + let off = cmdOffset; + while (off < end) { + const opcode = u16(bytes, off); + const size = u32(bytes, off + 4); + if (opcode === 2 && size >= 40) { + out.push({ + opcode, + fg: u32(bytes, off + 24), + bg: u32(bytes, off + 28), + attrs: u32(bytes, off + 32), + }); + } else if (opcode === 3 && size >= 48) { + out.push({ + opcode, + fg: u32(bytes, off + 28), + bg: u32(bytes, off + 32), + attrs: u32(bytes, off + 36), + }); + } + off += size; + } + + return Object.freeze(out); +} + +function packRgb(r: number, g: number, b: number): number { + return ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff); +} + function renderBytes( vnode: VNode, viewport: Readonly<{ cols: number; rows: number }> = { cols: 64, rows: 20 }, @@ -178,6 +220,115 @@ describe("renderer regressions", () => { assert.equal(withFillCount > withoutFillCount, true); }); + test("table column overflow policies render ellipsis, middle, and clip deterministically", () => { + const strings = parseInternedStrings( + renderBytes( + ui.table({ + id: "tbl-overflow", + columns: [ + { key: "ellipsis", header: "Ellipsis", width: 6 }, + { key: "middle", header: "Middle", width: 6, overflow: "middle" }, + { key: "clip", header: "Clip", width: 6, overflow: "clip" }, + ], + data: [ + { + ellipsis: "aaaaabbbbb", + middle: "cccccddddd", + clip: "eeeeefffff", + }, + ], + getRowKey: () => "row-0", + border: "none", + }), + { cols: 60, rows: 8 }, + ), + ); + + assert.equal(strings.includes("aaaaa…"), true); + assert.equal(strings.includes("ccc…dd"), true); + assert.equal(strings.includes("eeeeefffff"), true); + }); + + test("table stripeStyle applies custom odd/even row background colors", () => { + const bytes = renderBytes( + ui.table({ + id: "tbl-stripe-style", + columns: [{ key: "name", header: "Name", width: 12 }], + data: [{ name: "A" }, { name: "B" }, { name: "C" }], + getRowKey: (row) => row.name, + stripedRows: false, + stripeStyle: { + odd: { r: 1, g: 2, b: 3 }, + even: { r: 4, g: 5, b: 6 }, + }, + border: "none", + }), + { cols: 40, rows: 8 }, + ); + + const styles = parseCommandStyles(bytes); + assert.equal( + styles.some((s) => s.bg === packRgb(1, 2, 3)), + true, + ); + assert.equal( + styles.some((s) => s.bg === packRgb(4, 5, 6)), + true, + ); + }); + + test("table borderStyle applies custom variant and border color", () => { + const bytes = renderBytes( + ui.table({ + id: "tbl-border-style", + columns: [{ key: "name", header: "Name", width: 10 }], + data: [{ name: "A" }], + getRowKey: (row) => row.name, + border: "single", + borderStyle: { + variant: "double", + color: { r: 201, g: 202, b: 203 }, + }, + }), + { cols: 40, rows: 8 }, + ); + + const strings = parseInternedStrings(bytes); + const styles = parseCommandStyles(bytes); + assert.equal( + strings.some((s) => s.includes("═")), + true, + ); + assert.equal( + styles.some((s) => s.fg === packRgb(201, 202, 203)), + true, + ); + }); + + test("table border none keeps border hidden when borderStyle is provided", () => { + const strings = parseInternedStrings( + renderBytes( + ui.table({ + id: "tbl-border-none-style", + columns: [{ key: "name", header: "Name", width: 10 }], + data: [{ name: "A" }], + getRowKey: (row) => row.name, + border: "none", + borderStyle: { + variant: "double", + color: { r: 111, g: 112, b: 113 }, + }, + }), + { cols: 40, rows: 8 }, + ), + ); + + assert.equal( + strings.some((s) => s.includes("═") || s.includes("╔") || s.includes("╗")), + false, + ); + }); + test("dropdown renders shortcut text", () => { const bytes = renderBytes( ui.column({}, [ @@ -198,6 +349,211 @@ describe("renderer regressions", () => { assert.equal(strings.includes("Ctrl+O"), true); }); + test("modal backdrop config renders custom dim pattern and colors", () => { + const bytes = renderBytes( + ui.modal({ + id: "modal-backdrop-custom", + title: "Backdrop", + content: ui.text("Body"), + backdrop: { + variant: "dim", + pattern: "#", + foreground: { r: 1, g: 2, b: 3 }, + background: { r: 4, g: 5, b: 6 }, + }, + }), + { cols: 60, rows: 20 }, + ); + + const strings = parseInternedStrings(bytes); + const styles = parseCommandStyles(bytes); + assert.equal( + strings.some((s) => s.includes("###")), + true, + ); + assert.equal( + styles.some((s) => s.opcode === 3 && s.fg === packRgb(1, 2, 3) && s.bg === packRgb(4, 5, 6)), + true, + ); + }); + + test("modal backdrop object keeps legacy dim defaults when fields are omitted", () => { + const strings = parseInternedStrings( + renderBytes( + ui.modal({ + id: "modal-backdrop-defaults", + content: ui.text("Body"), + backdrop: { pattern: "" }, + }), + { cols: 60, rows: 20 }, + ), + ); + assert.equal( + strings.some((s) => s.includes("░")), + true, + ); + }); + + test("modal frameStyle encodes foreground, background, and border colors", () => { + const bytes = renderBytes( + ui.modal({ + id: "modal-frame-style", + content: ui.text("Styled modal"), + backdrop: "none", + frameStyle: { + background: { r: 12, g: 13, b: 14 }, + foreground: { r: 210, g: 211, b: 212 }, + border: { r: 90, g: 91, b: 92 }, + }, + }), + { cols: 60, rows: 20 }, + ); + + const styles = parseCommandStyles(bytes); + assert.equal( + styles.some((s) => s.bg === packRgb(12, 13, 14)), + true, + ); + assert.equal( + styles.some((s) => s.fg === packRgb(210, 211, 212)), + true, + ); + assert.equal( + styles.some((s) => s.fg === packRgb(90, 91, 92)), + true, + ); + }); + + test("layer frameStyle encodes foreground, background, and border colors", () => { + const bytes = renderBytes( + ui.layer({ + id: "layer-frame-style", + backdrop: "none", + frameStyle: { + background: { r: 21, g: 22, b: 23 }, + foreground: { r: 181, g: 182, b: 183 }, + border: { r: 101, g: 102, b: 103 }, + }, + content: ui.text("Layer styled"), + }), + { cols: 60, rows: 20 }, + ); + + const styles = parseCommandStyles(bytes); + assert.equal( + styles.some((s) => s.bg === packRgb(21, 22, 23)), + true, + ); + assert.equal( + styles.some((s) => s.fg === packRgb(181, 182, 183)), + true, + ); + assert.equal( + styles.some((s) => s.fg === packRgb(101, 102, 103)), + true, + ); + }); + + test("dropdown frameStyle encodes foreground, background, and border colors", () => { + const bytes = renderBytes( + ui.column({}, [ + ui.button("menu-anchor", "Menu"), + ui.dropdown({ + id: "styled-dropdown", + anchorId: "menu-anchor", + items: [ + { id: "new", label: "New" }, + { id: "open", label: "Open" }, + ], + frameStyle: { + background: { r: 31, g: 32, b: 33 }, + foreground: { r: 191, g: 192, b: 193 }, + border: { r: 111, g: 112, b: 113 }, + }, + }), + ]), + { cols: 60, rows: 20 }, + ); + + const styles = parseCommandStyles(bytes); + assert.equal( + styles.some((s) => s.bg === packRgb(31, 32, 33)), + true, + ); + assert.equal( + styles.some((s) => s.fg === packRgb(191, 192, 193)), + true, + ); + assert.equal( + styles.some((s) => s.fg === packRgb(111, 112, 113)), + true, + ); + }); + + test("commandPalette frameStyle encodes foreground, background, and border colors", () => { + const bytes = renderBytes( + ui.commandPalette({ + id: "palette-frame-style", + open: true, + query: "run", + sources: [{ id: "cmd", name: "Commands", getItems: () => [] }], + selectedIndex: 0, + frameStyle: { + background: { r: 41, g: 42, b: 43 }, + foreground: { r: 201, g: 202, b: 203 }, + border: { r: 121, g: 122, b: 123 }, + }, + onQueryChange: noop, + onSelect: noop, + onClose: noop, + }), + { cols: 70, rows: 24 }, + ); + + const styles = parseCommandStyles(bytes); + assert.equal( + styles.some((s) => s.bg === packRgb(41, 42, 43)), + true, + ); + assert.equal( + styles.some((s) => s.fg === packRgb(201, 202, 203)), + true, + ); + assert.equal( + styles.some((s) => s.fg === packRgb(121, 122, 123)), + true, + ); + }); + + test("toastContainer frameStyle encodes foreground, background, and border colors", () => { + const bytes = renderBytes( + ui.toastContainer({ + toasts: [{ id: "toast-1", message: "Saved", type: "success" }], + onDismiss: noop, + frameStyle: { + background: { r: 51, g: 52, b: 53 }, + foreground: { r: 211, g: 212, b: 213 }, + border: { r: 131, g: 132, b: 133 }, + }, + }), + { cols: 70, rows: 24 }, + ); + + const styles = parseCommandStyles(bytes); + assert.equal( + styles.some((s) => s.bg === packRgb(51, 52, 53)), + true, + ); + assert.equal( + styles.some((s) => s.fg === packRgb(211, 212, 213)), + true, + ); + assert.equal( + styles.some((s) => s.fg === packRgb(131, 132, 133)), + true, + ); + }); + test("virtualList clamps stale scrollTop after data shrink", () => { const virtualListStore = createVirtualListStateStore(); const viewport = { cols: 40, rows: 6 } as const; diff --git a/packages/core/src/widgets/__tests__/slider.test.ts b/packages/core/src/widgets/__tests__/slider.test.ts new file mode 100644 index 00000000..5f0159e4 --- /dev/null +++ b/packages/core/src/widgets/__tests__/slider.test.ts @@ -0,0 +1,83 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { SliderProps } from "../../index.js"; +import { + adjustSliderValue, + clampSliderValue, + createSliderVNode, + formatSliderValue, + normalizeSliderRange, + normalizeSliderState, + normalizeSliderStep, + quantizeSliderValue, +} from "../slider.js"; +import { ui } from "../ui.js"; + +describe("slider widget utilities", () => { + test("normalizeSliderRange defaults and swaps invalid bounds deterministically", () => { + assert.deepEqual(normalizeSliderRange(undefined, undefined), { min: 0, max: 100 }); + assert.deepEqual(normalizeSliderRange(10, -5), { min: -5, max: 10 }); + }); + + test("normalizeSliderStep clamps to span and handles collapsed ranges", () => { + assert.equal(normalizeSliderStep(200, { min: 0, max: 10 }), 10); + assert.equal(normalizeSliderStep(undefined, { min: 2, max: 2 }), 0); + }); + + test("quantizeSliderValue clamps and snaps to nearest step while preserving bounds", () => { + const range = { min: 0, max: 10 } as const; + assert.equal(quantizeSliderValue(-999, range, 3), 0); + assert.equal(quantizeSliderValue(999, range, 3), 10); + assert.equal(quantizeSliderValue(4.9, range, 2), 4); + assert.equal(quantizeSliderValue(0.31, { min: 0, max: 1 }, 0.2), 0.4); + }); + + test("normalizeSliderState clamps value and validates non-finite input", () => { + assert.deepEqual(normalizeSliderState({ value: Number.NaN, min: 5, max: 1, step: -2 }), { + min: 1, + max: 5, + step: 2, + value: 1, + }); + }); + + test("adjustSliderValue supports step/page/home/end with clamping", () => { + const state = { min: 0, max: 10, step: 3 } as const; + assert.equal(adjustSliderValue(0, state, "increase"), 3); + assert.equal(adjustSliderValue(3, state, "increasePage"), 10); + assert.equal(adjustSliderValue(10, state, "decreasePage"), 0); + assert.equal(adjustSliderValue(7, state, "toMin"), 0); + assert.equal(adjustSliderValue(7, state, "toMax"), 10); + }); + + test("clampSliderValue and formatSliderValue produce deterministic output", () => { + assert.equal(clampSliderValue(15, 0, 10), 10); + assert.equal(formatSliderValue(5, 1), "5"); + assert.equal(formatSliderValue(0.5, 0.1), "0.5"); + }); +}); + +describe("slider widget API", () => { + test("createSliderVNode and ui.slider preserve props", () => { + const props: SliderProps = { + id: "volume", + value: 50, + min: 0, + max: 100, + step: 5, + width: 12, + label: "Volume", + showValue: true, + disabled: false, + readOnly: false, + style: { bold: true }, + }; + + const fromFactory = createSliderVNode(props); + assert.equal(fromFactory.kind, "slider"); + assert.deepEqual(fromFactory.props, props); + + const fromUi = ui.slider(props); + assert.equal(fromUi.kind, "slider"); + assert.deepEqual(fromUi.props, props); + }); +}); diff --git a/packages/core/src/widgets/__tests__/table.typecheck.ts b/packages/core/src/widgets/__tests__/table.typecheck.ts new file mode 100644 index 00000000..bf62e64d --- /dev/null +++ b/packages/core/src/widgets/__tests__/table.typecheck.ts @@ -0,0 +1,49 @@ +import type { TableBorderStyle, TableColumn, TableProps } from "../types.js"; + +type Row = Readonly<{ id: string; path: string }>; + +const columnWithMiddleOverflow: TableColumn = { + key: "path", + header: "Path", + overflow: "middle", +}; + +const columnWithInvalidOverflow: TableColumn = { + key: "path", + header: "Path", + // @ts-expect-error invalid table column overflow mode + overflow: "fade", +}; + +const stripeStyle: NonNullable["stripeStyle"]> = { + odd: { r: 1, g: 2, b: 3 }, + even: { r: 4, g: 5, b: 6 }, +}; + +// @ts-expect-error missing b component +const stripeStyleInvalid: NonNullable["stripeStyle"]> = { odd: { r: 1, g: 2 } }; + +const borderStyle: TableBorderStyle = { + variant: "heavy-dashed", + color: { r: 10, g: 11, b: 12 }, +}; + +// @ts-expect-error invalid border style variant +const borderStyleInvalid: TableBorderStyle = { variant: "triple" }; + +const tableWithStyleOnly: TableProps = { + id: "table-typecheck", + columns: [columnWithMiddleOverflow], + data: [{ id: "r0", path: "/tmp/file.txt" }], + getRowKey: (row) => row.id, + stripeStyle, + borderStyle: { variant: "double", color: { r: 20, g: 21, b: 22 } }, +}; + +void columnWithMiddleOverflow; +void columnWithInvalidOverflow; +void stripeStyle; +void stripeStyleInvalid; +void borderStyle; +void borderStyleInvalid; +void tableWithStyleOnly; diff --git a/packages/core/src/widgets/__tests__/toast.test.ts b/packages/core/src/widgets/__tests__/toast.test.ts new file mode 100644 index 00000000..1cba6ef2 --- /dev/null +++ b/packages/core/src/widgets/__tests__/toast.test.ts @@ -0,0 +1,180 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { + DEFAULT_DURATION, + TOAST_HEIGHT, + addToast, + filterExpiredToasts, + getToastX, + getToastY, + removeToast, + updateToastProgress, +} from "../toast.js"; +import type { Toast, ToastPosition } from "../types.js"; + +const TOP_POSITIONS: readonly ToastPosition[] = ["top-left", "top-center", "top-right"]; +const BOTTOM_POSITIONS: readonly ToastPosition[] = ["bottom-left", "bottom-center", "bottom-right"]; +const ALL_POSITIONS: readonly ToastPosition[] = [...TOP_POSITIONS, ...BOTTOM_POSITIONS]; + +function makeToast(id: string, overrides: Partial = {}): Toast { + return { + id, + message: id, + type: "info", + ...overrides, + }; +} + +describe("toast helpers", () => { + test("getToastY stacks downward for all top positions", () => { + for (const position of TOP_POSITIONS) { + assert.equal(getToastY(0, position, 40), 0); + assert.equal(getToastY(2, position, 40), 2 * TOAST_HEIGHT); + } + }); + + test("getToastY stacks upward for all bottom positions", () => { + for (const position of BOTTOM_POSITIONS) { + assert.equal(getToastY(0, position, 20), 20 - TOAST_HEIGHT); + assert.equal(getToastY(1, position, 20), 20 - 2 * TOAST_HEIGHT); + } + }); + + test("getToastY handles invalid indices and clamps to container bounds", () => { + assert.equal(getToastY(-1, "top-left", 20), 0); + assert.equal(getToastY(Number.NaN, "top-left", 20), 0); + assert.equal(getToastY(Number.POSITIVE_INFINITY, "bottom-right", 20), 0); + assert.equal(getToastY(3, "bottom-left", 8), 0); + }); + + test("getToastX resolves left, center, and right offsets for all positions", () => { + for (const position of ALL_POSITIONS) { + const x = getToastX(position, 30, 10); + if (position.endsWith("left")) assert.equal(x, 0); + if (position.endsWith("center")) assert.equal(x, 10); + if (position.endsWith("right")) assert.equal(x, 20); + } + }); + + test("getToastX clamps center and right offsets when toast is wider than container", () => { + assert.equal(getToastX("top-center", 8, 12), 0); + assert.equal(getToastX("bottom-right", 8, 12), 0); + }); + + test("getToastX floors centered positions for odd differences", () => { + assert.equal(getToastX("bottom-center", 31, 10), 10); + }); + + test("filterExpiredToasts applies deterministic expiration math", () => { + const now = 10_000; + const toasts: readonly Toast[] = [ + makeToast("expired-custom", { duration: 1_000 }), + makeToast("fresh-custom", { duration: 1_000 }), + makeToast("expired-default"), + makeToast("fresh-default"), + makeToast("persistent", { duration: 0 }), + makeToast("missing-created", { duration: 1 }), + makeToast("future-created", { duration: 1_000 }), + ]; + + const createdAt = new Map([ + ["expired-custom", 9_000], + ["fresh-custom", 9_001], + ["expired-default", now - DEFAULT_DURATION], + ["fresh-default", now - DEFAULT_DURATION + 1], + ["persistent", 0], + ["future-created", 11_000], + ]); + + const result = filterExpiredToasts(toasts, now, createdAt); + assert.deepEqual( + result.map((toast) => toast.id), + ["fresh-custom", "fresh-default", "persistent", "missing-created", "future-created"], + ); + assert.equal(Object.isFrozen(result), true); + }); + + test("addToast prepends new toast and removes existing duplicates by id", () => { + const a0 = makeToast("a", { message: "old-a-0" }); + const b = makeToast("b"); + const a1 = makeToast("a", { message: "old-a-1" }); + const incoming = makeToast("a", { message: "new-a", type: "success" }); + + const source: readonly Toast[] = [a0, b, a1]; + const result = addToast(source, incoming); + + assert.deepEqual( + result.map((toast) => ({ id: toast.id, message: toast.message })), + [ + { id: "a", message: "new-a" }, + { id: "b", message: "b" }, + ], + ); + assert.equal(Object.isFrozen(result), true); + assert.deepEqual( + source.map((toast) => ({ id: toast.id, message: toast.message })), + [ + { id: "a", message: "old-a-0" }, + { id: "b", message: "b" }, + { id: "a", message: "old-a-1" }, + ], + ); + }); + + test("removeToast removes all matching ids and keeps missing-id removals as no-op", () => { + const a0 = makeToast("a"); + const b = makeToast("b"); + const a1 = makeToast("a", { message: "again-a" }); + const source: readonly Toast[] = [a0, b, a1]; + + const removed = removeToast(source, "a"); + assert.deepEqual( + removed.map((toast) => toast.id), + ["b"], + ); + assert.equal(Object.isFrozen(removed), true); + + const missing = removeToast(source, "missing"); + assert.deepEqual(missing, source); + assert.notEqual(missing, source); + }); + + test("updateToastProgress clips progress and updates all matching ids", () => { + const source: readonly Toast[] = [ + makeToast("a", { progress: 10 }), + makeToast("b", { progress: 30 }), + makeToast("a", { progress: 90 }), + ]; + + const high = updateToastProgress(source, "a", 150); + assert.deepEqual( + high.map((toast) => toast.progress), + [100, 30, 100], + ); + + const low = updateToastProgress(source, "a", -5); + assert.deepEqual( + low.map((toast) => toast.progress), + [0, 30, 0], + ); + + const exact = updateToastProgress(source, "a", 42.5); + assert.deepEqual( + exact.map((toast) => toast.progress), + [42.5, 30, 42.5], + ); + assert.equal(Object.isFrozen(exact), true); + }); + + test("updateToastProgress is a no-op for missing ids and preserves NaN behavior", () => { + const source: readonly Toast[] = [makeToast("a", { progress: 10 }), makeToast("b")]; + + const missing = updateToastProgress(source, "missing", 75); + assert.deepEqual(missing, source); + assert.notEqual(missing, source); + assert.equal(missing[0], source[0]); + assert.equal(missing[1], source[1]); + + const nan = updateToastProgress(source, "a", Number.NaN); + assert.equal(Number.isNaN(nan[0]?.progress), true); + }); +}); diff --git a/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts b/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts index c99ddff8..d93a01d1 100644 --- a/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts +++ b/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts @@ -109,6 +109,7 @@ describe("widget render smoke", () => { }, { name: "button", vnode: ui.button("ok", "OK") }, { name: "input", vnode: ui.input("name", "value") }, + { name: "slider", vnode: ui.slider({ id: "volume", value: 50, min: 0, max: 100, step: 5 }) }, { name: "focusZone", vnode: ui.focusZone({ id: "zone", navigation: "linear" }, [ui.button("z1", "One")]), diff --git a/packages/core/src/widgets/slider.ts b/packages/core/src/widgets/slider.ts new file mode 100644 index 00000000..ab68229b --- /dev/null +++ b/packages/core/src/widgets/slider.ts @@ -0,0 +1,150 @@ +/** + * packages/core/src/widgets/slider.ts — Slider widget utilities. + * + * Why: Centralizes slider range/step/value normalization so rendering and + * keyboard routing share deterministic behavior. + */ + +import type { SliderProps, VNode } from "./types.js"; + +export const DEFAULT_SLIDER_MIN = 0; +export const DEFAULT_SLIDER_MAX = 100; +export const DEFAULT_SLIDER_STEP = 1; +export const DEFAULT_SLIDER_TRACK_WIDTH = 10; + +export type SliderRange = Readonly<{ + min: number; + max: number; +}>; + +export type NormalizedSliderState = Readonly<{ + min: number; + max: number; + step: number; + value: number; +}>; + +export type SliderAdjustment = + | "decrease" + | "increase" + | "decreasePage" + | "increasePage" + | "toMin" + | "toMax"; + +function isFiniteNumber(v: unknown): v is number { + return typeof v === "number" && Number.isFinite(v); +} + +function decimalPlaces(value: number): number { + if (!Number.isFinite(value)) return 0; + const text = String(value).toLowerCase(); + const expIndex = text.indexOf("e-"); + if (expIndex >= 0) { + const frac = Number.parseInt(text.slice(expIndex + 2), 10); + return Number.isFinite(frac) ? frac : 0; + } + const dotIndex = text.indexOf("."); + if (dotIndex < 0) return 0; + return text.length - dotIndex - 1; +} + +function roundToPrecision(value: number, precision: number): number { + if (precision <= 0) return Math.round(value); + const factor = 10 ** precision; + return Math.round(value * factor) / factor; +} + +export function normalizeSliderRange(min?: number, max?: number): SliderRange { + const safeMin = isFiniteNumber(min) ? min : DEFAULT_SLIDER_MIN; + const safeMax = isFiniteNumber(max) ? max : DEFAULT_SLIDER_MAX; + if (safeMin <= safeMax) return { min: safeMin, max: safeMax }; + return { min: safeMax, max: safeMin }; +} + +export function clampSliderValue(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + if (value <= min) return min; + if (value >= max) return max; + return value; +} + +export function normalizeSliderStep(step: number | undefined, range: SliderRange): number { + const span = range.max - range.min; + if (span <= 0) return 0; + const rawStep = isFiniteNumber(step) ? Math.abs(step) : DEFAULT_SLIDER_STEP; + const safeStep = rawStep > 0 ? rawStep : DEFAULT_SLIDER_STEP; + return Math.min(safeStep, span); +} + +export function quantizeSliderValue(value: number, range: SliderRange, step: number): number { + const span = range.max - range.min; + if (span <= 0) return range.min; + + const safeStep = step > 0 && Number.isFinite(step) ? Math.min(step, span) : span; + const clamped = clampSliderValue(value, range.min, range.max); + if (clamped === range.min || clamped === range.max) return clamped; + const offset = clamped - range.min; + const snapped = range.min + Math.round(offset / safeStep) * safeStep; + const precision = Math.min( + 8, + Math.max(decimalPlaces(range.min), decimalPlaces(range.max), decimalPlaces(safeStep)), + ); + return clampSliderValue(roundToPrecision(snapped, precision), range.min, range.max); +} + +export function normalizeSliderState( + props: Readonly<{ + value: number; + min?: number | undefined; + max?: number | undefined; + step?: number | undefined; + }>, +): NormalizedSliderState { + const range = normalizeSliderRange(props.min, props.max); + const step = normalizeSliderStep(props.step, range); + const rawValue = isFiniteNumber(props.value) ? props.value : range.min; + const value = step <= 0 ? range.min : quantizeSliderValue(rawValue, range, step); + return { min: range.min, max: range.max, step, value }; +} + +export function adjustSliderValue( + currentValue: number, + state: Readonly<{ min: number; max: number; step: number }>, + adjustment: SliderAdjustment, +): number { + const range = normalizeSliderRange(state.min, state.max); + const step = normalizeSliderStep(state.step, range); + if (step <= 0) return range.min; + if (adjustment === "toMin") return range.min; + if (adjustment === "toMax") return range.max; + + const deltaMultiplier = + adjustment === "decrease" + ? -1 + : adjustment === "increase" + ? 1 + : adjustment === "decreasePage" + ? -10 + : 10; + const base = quantizeSliderValue(currentValue, range, step); + return quantizeSliderValue(base + step * deltaMultiplier, range, step); +} + +export function formatSliderValue(value: number, step: number): string { + const clampedPrecision = Math.min( + 8, + step > 0 ? decimalPlaces(step) : Math.max(0, decimalPlaces(value)), + ); + const rounded = roundToPrecision(value, clampedPrecision); + if (clampedPrecision <= 0) return String(rounded); + const fixed = rounded.toFixed(clampedPrecision); + return fixed.replace(/\.?0+$/, ""); +} + +export function createSliderVNode(props: SliderProps): VNode { + return { + kind: "slider", + props, + }; +} diff --git a/packages/core/src/widgets/types.ts b/packages/core/src/widgets/types.ts index 48ae45ba..e6d2c3cd 100644 --- a/packages/core/src/widgets/types.ts +++ b/packages/core/src/widgets/types.ts @@ -544,9 +544,33 @@ export type VirtualListProps = Readonly<{ /* ========== Layer System (GitHub issue #117) ========== */ -/** Backdrop style for modals and overlays. */ +/** Backdrop style presets for modals and overlays. */ export type BackdropStyle = "none" | "dim" | "opaque"; +/** Shared frame/surface styling for overlay widgets. */ +export type OverlayFrameStyle = Readonly<{ + /** Surface background color. */ + background?: NonNullable; + /** Default text/icon color for overlay content. */ + foreground?: NonNullable; + /** Border color for framed overlays. */ + border?: NonNullable; +}>; + +/** Extended modal backdrop config (preset-compatible). */ +export type ModalBackdrop = + | BackdropStyle + | Readonly<{ + /** Backdrop variant (default: "dim"). */ + variant?: BackdropStyle; + /** Optional dim-pattern character (default: "░", dim variant only). */ + pattern?: string; + /** Optional backdrop foreground color. */ + foreground?: NonNullable; + /** Optional backdrop background color. */ + background?: NonNullable; + }>; + /** Position for dropdown relative to anchor. */ export type DropdownPosition = | "below-start" @@ -575,8 +599,10 @@ export type ModalProps = Readonly<{ width?: number | "auto"; /** Maximum width constraint. */ maxWidth?: number; - /** Backdrop style (default: "dim"). */ - backdrop?: BackdropStyle; + /** Frame/surface colors for modal body and border. */ + frameStyle?: OverlayFrameStyle; + /** Backdrop style/config (default: "dim"). */ + backdrop?: ModalBackdrop; /** Close when backdrop is clicked (default: true). */ closeOnBackdrop?: boolean; /** Close when ESC is pressed (default: true). */ @@ -607,6 +633,8 @@ export type DropdownProps = Readonly<{ anchorId: string; /** Position relative to anchor (default: "below-start"). */ position?: DropdownPosition; + /** Frame/surface colors for dropdown background, text, and border. */ + frameStyle?: OverlayFrameStyle; /** Menu items to render. */ items: readonly DropdownItem[]; /** Callback when an item is selected. */ @@ -624,6 +652,8 @@ export type LayerProps = Readonly<{ * Values are truncated to integers and clamped to `±9,007,199,253` for deterministic ordering. */ zIndex?: number; + /** Frame/surface colors for the layer container. */ + frameStyle?: OverlayFrameStyle; /** Backdrop to render behind content. */ backdrop?: BackdropStyle; /** Whether layer blocks input to lower layers. */ @@ -638,6 +668,34 @@ export type LayerProps = Readonly<{ /* ========== Table Widget (GitHub issue #118) ========== */ +/** Overflow behavior for table header/cell text. */ +export type TableColumnOverflow = "clip" | "ellipsis" | "middle"; + +/** Row stripe styling for table body backgrounds. */ +export type TableStripeStyle = Readonly<{ + /** Background color for odd body rows (0-based index: 1, 3, 5, ...). */ + odd?: NonNullable; + /** Background color for even body rows (0-based index: 0, 2, 4, ...). */ + even?: NonNullable; +}>; + +/** Border glyph variants supported by table borders. */ +export type TableBorderVariant = + | "single" + | "double" + | "rounded" + | "heavy" + | "dashed" + | "heavy-dashed"; + +/** Border styling options for tables. */ +export type TableBorderStyle = Readonly<{ + /** Border glyph variant (default: "single"). */ + variant?: TableBorderVariant; + /** Border foreground color override. */ + color?: NonNullable; +}>; + /** Column definition for table widget. */ export type TableColumn = Readonly<{ /** Unique column identifier. */ @@ -656,6 +714,8 @@ export type TableColumn = Readonly<{ render?: (value: unknown, row: T, index: number) => VNode; /** Cell content alignment. */ align?: "left" | "center" | "right"; + /** Overflow handling for this column (default: "ellipsis"). */ + overflow?: TableColumnOverflow; /** Whether column is sortable. */ sortable?: boolean; }>; @@ -694,12 +754,16 @@ export type TableProps = Readonly<{ virtualized?: boolean; /** Number of rows to render outside viewport (default: 3). */ overscan?: number; - /** Alternate row background colors. */ + /** Legacy stripe toggle. */ stripedRows?: boolean; + /** Stripe background styling for body rows. */ + stripeStyle?: TableStripeStyle; /** Show header row (default: true). */ showHeader?: boolean; - /** Border style. */ + /** Legacy border toggle. */ border?: "none" | "single"; + /** Border styling for rendered table frame. */ + borderStyle?: TableBorderStyle; }>; /* ========== Form Widgets (GitHub issue #119) ========== */ @@ -745,6 +809,34 @@ export type SelectProps = Readonly<{ placeholder?: string; }>; +/** Props for slider widget. */ +export type SliderProps = Readonly<{ + id: string; + key?: string; + /** Current slider value. */ + value: number; + /** Minimum value (default: 0). */ + min?: number; + /** Maximum value (default: 100). */ + max?: number; + /** Step increment for keyboard changes (default: 1). */ + step?: number; + /** Optional fixed track width in cells (default: fills available width). */ + width?: number; + /** Optional label shown before the track. */ + label?: string; + /** Show numeric value text (default: true). */ + showValue?: boolean; + /** Callback when value changes. */ + onChange?: (value: number) => void; + /** Whether the slider is disabled. */ + disabled?: boolean; + /** Whether the slider is read-only (focusable but non-editable). */ + readOnly?: boolean; + /** Optional style applied to label/value text. */ + style?: TextStyle; +}>; + /** Props for checkbox widget. */ export type CheckboxProps = Readonly<{ id: string; @@ -832,6 +924,8 @@ export type CommandPaletteProps = Readonly<{ placeholder?: string; /** Maximum visible items (default: 10). */ maxVisible?: number; + /** Frame/surface colors for palette background, text, and border. */ + frameStyle?: OverlayFrameStyle; /** Callback when query changes. */ onQueryChange: (query: string) => void; /** Callback when item is selected. */ @@ -1311,6 +1405,8 @@ export type ToastContainerProps = Readonly<{ position?: ToastPosition; /** Maximum visible toasts (default: 5). */ maxVisible?: number; + /** Frame/surface colors for toast backgrounds, text, and borders. */ + frameStyle?: OverlayFrameStyle; /** Callback when toast is dismissed. */ onDismiss: (id: string) => void; }>; @@ -1395,6 +1491,7 @@ export type VNode = | Readonly<{ kind: "miniChart"; props: MiniChartProps }> | Readonly<{ kind: "button"; props: ButtonProps }> | Readonly<{ kind: "input"; props: InputProps }> + | Readonly<{ kind: "slider"; props: SliderProps }> | Readonly<{ kind: "focusZone"; props: FocusZoneProps; children: readonly VNode[] }> | Readonly<{ kind: "focusTrap"; props: FocusTrapProps; children: readonly VNode[] }> | Readonly<{ kind: "virtualList"; props: VirtualListProps }> diff --git a/packages/core/src/widgets/ui.ts b/packages/core/src/widgets/ui.ts index 8ba46f84..b3149ca4 100644 --- a/packages/core/src/widgets/ui.ts +++ b/packages/core/src/widgets/ui.ts @@ -46,6 +46,7 @@ import type { RichTextSpan, SelectProps, SkeletonProps, + SliderProps, SpacerProps, SparklineProps, SpinnerProps, @@ -602,8 +603,8 @@ export const ui = { * ui.table({ * id: "files", * columns: [ - * { key: "name", header: "Name", flex: 1, sortable: true }, - * { key: "size", header: "Size", width: 10, align: "right" }, + * { key: "name", header: "Name", flex: 1, sortable: true, overflow: "middle" }, + * { key: "size", header: "Size", width: 10, align: "right", overflow: "clip" }, * { key: "actions", header: "", width: 8, render: (_, row) => * ui.button({ id: `del-${row.id}`, label: "Del" }) }, * ], @@ -615,6 +616,8 @@ export const ui = { * sortColumn: state.sortCol, * sortDirection: state.sortDir, * onSort: (col, dir) => app.update({ sortCol: col, sortDir: dir }), + * stripeStyle: { odd: { r: 30, g: 33, b: 41 } }, + * borderStyle: { variant: "double", color: { r: 130, g: 140, b: 150 } }, * }) * ``` */ @@ -703,6 +706,26 @@ export const ui = { return { kind: "select", props }; }, + /** + * Create a slider widget. + * Supports keyboard adjustment with Left/Right (or Up/Down), Home/End, and PageUp/PageDown. + * + * @example + * ```ts + * ui.slider({ + * id: "volume", + * value: state.volume, + * min: 0, + * max: 100, + * step: 5, + * onChange: (value) => app.update({ volume: value }), + * }) + * ``` + */ + slider(props: SliderProps): VNode { + return { kind: "slider", props }; + }, + /** * Create a checkbox widget. * Toggles with Space key.