diff --git a/examples/demo-animations.ts b/examples/demo-animations.ts new file mode 100644 index 00000000..92bfb6cf --- /dev/null +++ b/examples/demo-animations.ts @@ -0,0 +1,61 @@ +import { App, Screen } from '@termuijs/core'; +import { Box, Text, Checkbox } from '@termuijs/widgets'; +import { Switch } from '@termuijs/ui'; + +async function main() { + const root = new Box({ flexDirection: 'column', gap: 1, padding: { left: 2 } }); + + root.addChild(new Text(' Checkbox & Switch Animation Demo', { + bold: true, fg: { type: 'named', name: 'cyan' }, height: 1, + })); + root.addChild(new Text(' Press Space/Enter to toggle • q to quit', { + fg: { type: 'named', name: 'brightBlack' }, height: 1, + })); + root.addChild(new Text('─'.repeat(40), { fg: { type: 'named', name: 'brightBlack' }, height: 1 })); + + const cb1 = new Checkbox('Enable notifications', {}, { checked: true }); + const cb2 = new Checkbox('Dark mode', {}, { checked: false }); + const cb3 = new Checkbox('Auto-save', {}, { checked: true }); + + const sw1 = new Switch({ defaultValue: true, label: 'Wi-Fi' }); + const sw2 = new Switch({ defaultValue: false, label: 'Bluetooth' }); + const sw3 = new Switch({ defaultValue: true, label: 'Airplane mode' }); + + root.addChild(new Text(' Checkboxes:', { bold: true, height: 1 })); + root.addChild(cb1); + root.addChild(cb2); + root.addChild(cb3); + + root.addChild(new Text('', { height: 1 })); + + root.addChild(new Text(' Switches:', { bold: true, height: 1 })); + root.addChild(sw1); + root.addChild(sw2); + root.addChild(sw3); + + const app = new App(root, { fullscreen: true, fps: 30, title: 'Animation Demo' }); + + app.events.on('key', (event) => { + if (event.key === 'q' || (event.ctrl && event.key === 'c')) { + app.exit(0); + return; + } + if (event.key === 'space' || event.key === 'enter') { + cb1.toggle(); + cb2.toggle(); + cb3.toggle(); + sw1.toggle(); + sw2.toggle(); + sw3.toggle(); + } + app.requestRender(); + }); + + const exitCode = await app.mount(); + process.exit(exitCode); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index 5ad145d1..b0188773 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -32,6 +32,7 @@ "@standard-schema/spec": "^1.1.0", "@termuijs/core": "workspace:*", "@termuijs/jsx": "workspace:*", + "@termuijs/motion": "workspace:*", "@termuijs/tss": "workspace:*", "@termuijs/widgets": "workspace:*" }, diff --git a/packages/ui/src/Switch.ts b/packages/ui/src/Switch.ts index c298b48f..46988dcc 100644 --- a/packages/ui/src/Switch.ts +++ b/packages/ui/src/Switch.ts @@ -5,8 +5,11 @@ import { mergeStyles, defaultStyle, styleToCellAttrs, + stringWidth, caps, + prefersReducedMotion, } from '@termuijs/core'; +import { fadeIn, fadeOut } from '@termuijs/motion'; export interface SwitchOptions { defaultValue?: boolean; @@ -18,6 +21,8 @@ export class Switch extends Widget { private _value: boolean; private _label?: string; onChange?: (value: boolean) => void; + private _animProgress: number; + private _animCancel?: () => void; focusable = true; @@ -27,6 +32,7 @@ export class Switch extends Widget { this._value = options.defaultValue ?? false; this._label = options.label; this.onChange = options.onChange; + this._animProgress = this._value ? 1 : 0; } get value(): boolean { @@ -38,13 +44,53 @@ export class Switch extends Widget { this._value = value; this.onChange?.(value); + this._animCancel?.(); this.markDirty(); + + if (prefersReducedMotion()) { + this._animProgress = value ? 1 : 0; + return; + } + + if (value) { + this._animProgress = 0; + this._animCancel = fadeIn(150, (p) => { + this._animProgress = p; + this.markDirty(); + }, () => { + this._animProgress = 1; + this._animCancel = undefined; + }); + } else { + this._animProgress = 1; + this._animCancel = fadeOut(150, (p) => { + this._animProgress = p; + this.markDirty(); + }, () => { + this._animProgress = 0; + this._animCancel = undefined; + }); + } } toggle(): void { this.setValue(!this._value); } + mount(): void { + super.mount(); + if (this._value && this._animProgress < 1) { + this._animProgress = 1; + this.markDirty(); + } + } + + unmount(): void { + this._animCancel?.(); + this._animCancel = undefined; + super.unmount(); + } + handleKey(event: KeyEvent): void { switch (event.key) { case 'space': @@ -67,20 +113,35 @@ export class Switch extends Widget { if (width <= 0) return; const attrs = styleToCellAttrs(this.style); + const knobPos = Math.round(this._animProgress * 2); + const transitioning = this._animProgress > 0 && this._animProgress < 1; + + let trackChars: string[]; + let knobChar: string; + if (caps.unicode) { + trackChars = ['─', '─', '─']; + knobChar = '●'; + } else { + trackChars = ['-', '-', '-']; + knobChar = 'O'; + } - const track = caps.unicode - ? (this._value ? '──●' : '●──') - : (this._value ? '--O' : 'O--'); + let cursorX = x; - const text = this._label - ? `${this._label} ${track}` - : track; + if (this._label) { + screen.writeString(cursorX, y, `${this._label} `, attrs); + cursorX += stringWidth(`${this._label} `); + } - screen.writeString( - x, - y, - text.slice(0, width), - attrs - ); + for (let i = 0; i < 3; i++) { + const isKnob = i === knobPos; + const isOn = this._value; + screen.setCell(cursorX + i, y, { + char: isKnob ? knobChar : trackChars[i], + fg: attrs.fg, + dim: transitioning || (!isKnob && !isOn), + bold: isKnob, + }); + } } } \ No newline at end of file diff --git a/packages/widgets/src/display/Tooltip.test.ts b/packages/widgets/src/display/Tooltip.test.ts index ae0f99a1..6ed463b3 100644 --- a/packages/widgets/src/display/Tooltip.test.ts +++ b/packages/widgets/src/display/Tooltip.test.ts @@ -1,8 +1,11 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { Tooltip } from "./Tooltip.js"; -import { Screen, caps } from "@termuijs/core"; -import { vi } from 'vitest'; +import { Screen, caps, prefersReducedMotion } from "@termuijs/core"; +import * as motion from "@termuijs/motion"; +afterEach(() => { + vi.restoreAllMocks(); +}); function renderTooltip(text = "help", visible = true, width = 20, height = 5) { const tooltip = new Tooltip({ @@ -66,7 +69,6 @@ describe("Tooltip", () => { vi.spyOn(caps, "unicode", "get").mockReturnValue(false); const { screen } = renderTooltip(); expect(screen.back[0][0].char).toBe("+"); - vi.restoreAllMocks(); }); it("setText marks widget dirty", () => { const tooltip = new Tooltip({ @@ -93,6 +95,77 @@ describe("Tooltip", () => { expect(tooltip.getVisible()).toBe(false); }); + it("setVisible(true) starts fade-in (animOpacity resets to 0)", () => { + vi.spyOn(caps, "motion", "get").mockReturnValue(true); + + const tooltip = new Tooltip({ + text: "help", + visible: false, + }); + + expect((tooltip as any)._animOpacity).toBe(0); + + tooltip.setVisible(true); + + expect((tooltip as any)._animOpacity).toBe(0); + expect(tooltip.getVisible()).toBe(true); + }); + it("setVisible(false) during fade-in cancels previous animation", () => { + vi.spyOn(caps, "motion", "get").mockReturnValue(true); + + const tooltip = new Tooltip({ + text: "help", + visible: true, + }); + + const cancel = vi.fn(); + vi.spyOn(motion, "fadeOut").mockReturnValue(cancel); + + tooltip.setVisible(false); + + expect(motion.fadeOut).toHaveBeenCalledTimes(1); + }); + it("renders with dim attribute during fade-out", () => { + vi.spyOn(caps, "motion", "get").mockReturnValue(true); + vi.spyOn(motion, "fadeOut").mockImplementation( + (_duration, onFrame, _onComplete) => { + onFrame(0.3); + return vi.fn(); + }, + ); + + const tooltip = new Tooltip({ + text: "dimmed", + visible: true, + }); + const screen = new Screen(20, 5); + tooltip.updateRect({ x: 0, y: 0, width: 20, height: 5 }); + + tooltip.setVisible(false); + tooltip.render(screen); + + expect(screen.back[0][0].dim).toBe(true); + }); + it("renders without dim when animOpacity is above threshold", () => { + vi.spyOn(motion, "fadeIn").mockImplementation( + (_duration, onFrame, _onComplete) => { + onFrame(0.7); + return vi.fn(); + }, + ); + + const tooltip = new Tooltip({ + text: "bright", + visible: false, + }); + const screen = new Screen(20, 5); + tooltip.updateRect({ x: 0, y: 0, width: 20, height: 5 }); + + tooltip.setVisible(true); + tooltip.render(screen); + + expect(screen.back[0][0].dim).toBe(false); + }); }); describe("Tooltip – mutation regression tests", () => { @@ -145,4 +218,64 @@ describe("Tooltip – mutation regression tests", () => { expect(tooltip.getVisible()).toBe(false); expect(tooltip.isDirty).toBe(true); }); -}); \ No newline at end of file +}); + +describe("Tooltip – reduced motion", () => { + it("skips fade-in when prefersReducedMotion is true", () => { + vi.spyOn(caps, "motion", "get").mockReturnValue(false); + + const tooltip = new Tooltip({ + text: "help", + visible: false, + }); + + tooltip.setVisible(true); + + expect((tooltip as any)._animOpacity).toBe(1); + }); + + it("skips fade-out when prefersReducedMotion is true", () => { + vi.spyOn(caps, "motion", "get").mockReturnValue(false); + + const tooltip = new Tooltip({ + text: "help", + visible: true, + }); + + tooltip.setVisible(false); + + expect((tooltip as any)._animOpacity).toBe(0); + }); +}); + +describe("Tooltip – mount/unmount", () => { + it("restores animOpacity on mount if visible", () => { + const tooltip = new Tooltip({ + text: "help", + visible: true, + }); + + (tooltip as any)._animOpacity = 0; + tooltip.mount(); + + expect((tooltip as any)._animOpacity).toBe(1); + }); + + it("cancels animation on unmount", () => { + vi.spyOn(caps, "motion", "get").mockReturnValue(true); + + const cancel = vi.fn(); + const tooltip = new Tooltip({ + text: "help", + visible: false, + }); + + vi.spyOn(motion, "fadeIn").mockReturnValue(cancel); + + tooltip.setVisible(true); + + tooltip.unmount(); + + expect(cancel).toHaveBeenCalled(); + }); +}); diff --git a/packages/widgets/src/display/Tooltip.ts b/packages/widgets/src/display/Tooltip.ts index fc246873..272ba7a2 100644 --- a/packages/widgets/src/display/Tooltip.ts +++ b/packages/widgets/src/display/Tooltip.ts @@ -1,6 +1,13 @@ // @termuijs/widgets — Tooltip widget -import { type Screen, type Style, caps } from '@termuijs/core'; +import { + type Screen, + type Style, + caps, + prefersReducedMotion, + styleToCellAttrs, +} from '@termuijs/core'; +import { fadeIn, fadeOut } from '@termuijs/motion'; import { Widget } from '../base/Widget.js'; export interface TooltipOptions { @@ -13,25 +20,51 @@ export interface TooltipOptions { * * The widget renders within its own assigned rect. * The parent is responsible for positioning it via updateRect(). + * + * On show/hide the tooltip plays a short fade-in/fade-out animation + * (150ms) using the terminal's `dim` attribute to simulate opacity. + * The animation is skipped when prefersReducedMotion() is true. */ export class Tooltip extends Widget { private _text: string; private _visible: boolean; + private _animOpacity: number; + private _animCancel?: () => void; constructor(options: TooltipOptions, style: Partial