Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions examples/demo-animations.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Comment thread
Shan7Usmani marked this conversation as resolved.

const exitCode = await app.mount();
process.exit(exitCode);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
},
Expand Down
85 changes: 73 additions & 12 deletions packages/ui/src/Switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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 {
Expand All @@ -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();
}
}
Comment thread
Shan7Usmani marked this conversation as resolved.

unmount(): void {
this._animCancel?.();
this._animCancel = undefined;
super.unmount();
}

handleKey(event: KeyEvent): void {
switch (event.key) {
case 'space':
Expand All @@ -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,
});
}
}
}
143 changes: 138 additions & 5 deletions packages/widgets/src/display/Tooltip.test.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Comment thread
Shan7Usmani marked this conversation as resolved.

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", () => {
Expand Down Expand Up @@ -145,4 +218,64 @@ describe("Tooltip – mutation regression tests", () => {
expect(tooltip.getVisible()).toBe(false);
expect(tooltip.isDirty).toBe(true);
});
});
});

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();
});
});
Loading
Loading