Skip to content

Commit

Permalink
improve types event emitter & global aliases (#147)
Browse files Browse the repository at this point in the history
  • Loading branch information
natemoo-re authored Dec 14, 2024
2 parents 2bbd33e + 4c535a1 commit 2770eda
Show file tree
Hide file tree
Showing 11 changed files with 209 additions and 89 deletions.
5 changes: 5 additions & 0 deletions .changeset/swift-jars-destroy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clack/core': patch
---

Improves types for events and interaction states.
8 changes: 8 additions & 0 deletions examples/basic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ async function main() {

await setTimeout(1000);

p.setGlobalAliases([
['w', 'up'],
['s', 'down'],
['a', 'left'],
['d', 'right'],
['escape', 'cancel'],
]);

p.intro(`${color.bgCyan(color.black(' create-app '))}`);

const project = await p.group(
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ export { default as ConfirmPrompt } from './prompts/confirm';
export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect';
export { default as MultiSelectPrompt } from './prompts/multi-select';
export { default as PasswordPrompt } from './prompts/password';
export { default as Prompt, isCancel } from './prompts/prompt';
export type { State } from './prompts/prompt';
export { default as Prompt } from './prompts/prompt';
export { default as SelectPrompt } from './prompts/select';
export { default as SelectKeyPrompt } from './prompts/select-key';
export { default as TextPrompt } from './prompts/text';
export { block } from './utils';
export type { ClackState as State } from './types';
export { block, isCancel, setGlobalAliases } from './utils';

157 changes: 80 additions & 77 deletions packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,13 @@
import type { Key, ReadLine } from 'node:readline';

import { stdin, stdout } from 'node:process';
import readline from 'node:readline';
import readline, { type Key, type ReadLine } from 'node:readline';
import { Readable, Writable } from 'node:stream';
import { WriteStream } from 'node:tty';
import { cursor, erase } from 'sisteransi';
import wrap from 'wrap-ansi';

function diffLines(a: string, b: string) {
if (a === b) return;

const aLines = a.split('\n');
const bLines = b.split('\n');
const diff: number[] = [];

for (let i = 0; i < Math.max(aLines.length, bLines.length); i++) {
if (aLines[i] !== bLines[i]) diff.push(i);
}

return diff;
}

const cancel = Symbol('clack:cancel');
export function isCancel(value: unknown): value is symbol {
return value === cancel;
}

function setRawMode(input: Readable, value: boolean) {
if ((input as typeof stdin).isTTY) (input as typeof stdin).setRawMode(value);
}
import { ALIASES, CANCEL_SYMBOL, diffLines, hasAliasKey, KEYS, setRawMode } from '../utils';

const aliases = new Map([
['k', 'up'],
['j', 'down'],
['h', 'left'],
['l', 'right'],
]);
const keys = new Set(['up', 'down', 'left', 'right', 'space', 'enter']);
import type { ClackEvents, ClackState, InferSetType } from '../types';

export interface PromptOptions<Self extends Prompt> {
render(this: Omit<Self, 'prompt'>): string | void;
Expand All @@ -48,25 +19,25 @@ export interface PromptOptions<Self extends Prompt> {
debug?: boolean;
}

export type State = 'initial' | 'active' | 'cancel' | 'submit' | 'error';

export default class Prompt {
protected input: Readable;
protected output: Writable;

private rl!: ReadLine;
private opts: Omit<PromptOptions<Prompt>, 'render' | 'input' | 'output'>;
private _track: boolean = false;
private _render: (context: Omit<Prompt, 'prompt'>) => string | void;
protected _cursor: number = 0;
private _track = false;
private _prevFrame = '';
private _subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>();
protected _cursor = 0;

public state: State = 'initial';
public state: ClackState = 'initial';
public error = '';
public value: any;
public error: string = '';

constructor(
{ render, input = stdin, output = stdout, ...opts }: PromptOptions<Prompt>,
trackValue: boolean = true
) {
constructor(options: PromptOptions<Prompt>, trackValue: boolean = true) {
const { input = stdin, output = stdout, render, ...opts } = options;

this.opts = opts;
this.onKeypress = this.onKeypress.bind(this);
this.close = this.close.bind(this);
Expand All @@ -78,6 +49,66 @@ export default class Prompt {
this.output = output;
}

/**
* Unsubscribe all listeners
*/
protected unsubscribe() {
this._subscribers.clear();
}

/**
* Set a subscriber with opts
* @param event - The event name
*/
private setSubscriber<T extends keyof ClackEvents>(
event: T,
opts: { cb: ClackEvents[T]; once?: boolean }
) {
const params = this._subscribers.get(event) ?? [];
params.push(opts);
this._subscribers.set(event, params);
}

/**
* Subscribe to an event
* @param event - The event name
* @param cb - The callback
*/
public on<T extends keyof ClackEvents>(event: T, cb: ClackEvents[T]) {
this.setSubscriber(event, { cb });
}

/**
* Subscribe to an event once
* @param event - The event name
* @param cb - The callback
*/
public once<T extends keyof ClackEvents>(event: T, cb: ClackEvents[T]) {
this.setSubscriber(event, { cb, once: true });
}

/**
* Emit an event with data
* @param event - The event name
* @param data - The data to pass to the callback
*/
public emit<T extends keyof ClackEvents>(event: T, ...data: Parameters<ClackEvents[T]>) {
const cbs = this._subscribers.get(event) ?? [];
const cleanup: (() => void)[] = [];

for (const subscriber of cbs) {
subscriber.cb(...data);

if (subscriber.once) {
cleanup.push(() => cbs.splice(cbs.indexOf(subscriber), 1));
}
}

for (const cb of cleanup) {
cb();
}
}

public prompt() {
const sink = new WriteStream(0);
sink._write = (chunk, encoding, done) => {
Expand Down Expand Up @@ -120,48 +151,20 @@ export default class Prompt {
this.output.write(cursor.show);
this.output.off('resize', this.render);
setRawMode(this.input, false);
resolve(cancel);
resolve(CANCEL_SYMBOL);
});
});
}

private subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>();
public on(event: string, cb: (...args: any) => any) {
const arr = this.subscribers.get(event) ?? [];
arr.push({ cb });
this.subscribers.set(event, arr);
}
public once(event: string, cb: (...args: any) => any) {
const arr = this.subscribers.get(event) ?? [];
arr.push({ cb, once: true });
this.subscribers.set(event, arr);
}
public emit(event: string, ...data: any[]) {
const cbs = this.subscribers.get(event) ?? [];
const cleanup: (() => void)[] = [];
for (const subscriber of cbs) {
subscriber.cb(...data);
if (subscriber.once) {
cleanup.push(() => cbs.splice(cbs.indexOf(subscriber), 1));
}
}
for (const cb of cleanup) {
cb();
}
}
private unsubscribe() {
this.subscribers.clear();
}

private onKeypress(char: string, key?: Key) {
if (this.state === 'error') {
this.state = 'active';
}
if (key?.name && !this._track && aliases.has(key.name)) {
this.emit('cursor', aliases.get(key.name));
if (key?.name && !this._track && ALIASES.has(key.name)) {
this.emit('cursor', ALIASES.get(key.name));
}
if (key?.name && keys.has(key.name)) {
this.emit('cursor', key.name);
if (key?.name && KEYS.has(key.name as InferSetType<typeof KEYS>)) {
this.emit('cursor', key.name as InferSetType<typeof KEYS>);
}
if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) {
this.emit('confirm', char.toLowerCase() === 'y');
Expand Down Expand Up @@ -189,7 +192,8 @@ export default class Prompt {
this.state = 'submit';
}
}
if (char === '\x03') {

if (hasAliasKey([key?.name, key?.sequence], 'cancel')) {
this.state = 'cancel';
}
if (this.state === 'submit' || this.state === 'cancel') {
Expand Down Expand Up @@ -217,7 +221,6 @@ export default class Prompt {
this.output.write(cursor.move(-999, lines * -1));
}

private _prevFrame = '';
private render() {
const frame = wrap(this._render(this) ?? '', process.stdout.columns, { hard: true });
if (frame === this._prevFrame) return;
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { KEYS } from './utils';

export type InferSetType<T> = T extends Set<infer U> ? U : never;

/**
* The state of the prompt
*/
export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error';

/**
* Typed event emitter for clack
*/
export interface ClackEvents {
initial: (value?: any) => void;
active: (value?: any) => void;
cancel: (value?: any) => void;
submit: (value?: any) => void;
error: (value?: any) => void;
cursor: (key?: InferSetType<typeof KEYS>) => void;
key: (key?: string) => void;
value: (value?: string) => void;
confirm: (value?: boolean) => void;
finalize: () => void;
}
49 changes: 49 additions & 0 deletions packages/core/src/utils/aliases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { InferSetType } from '../types';

const DEFAULT_KEYS = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const;
export const KEYS = new Set(DEFAULT_KEYS);

export const ALIASES = new Map<string, InferSetType<typeof KEYS>>([
['k', 'up'],
['j', 'down'],
['h', 'left'],
['l', 'right'],
['\x03', 'cancel'],
]);

/**
* Set custom global aliases for the default keys - This will not overwrite existing aliases just add new ones
*
* @param aliases - A map of aliases to keys
* @default
* new Map([['k', 'up'], ['j', 'down'], ['h', 'left'], ['l', 'right'], ['\x03', 'cancel'],])
*/
export function setGlobalAliases(alias: Array<[string, InferSetType<typeof KEYS>]>) {
for (const [newAlias, key] of alias) {
if (!ALIASES.has(newAlias)) {
ALIASES.set(newAlias, key);
}
}
}

/**
* Check if a key is an alias for a default key
* @param key - The key to check for
* @param type - The type of key to check for
* @returns boolean
*/
export function hasAliasKey(
key: string | Array<string | undefined>,
type: InferSetType<typeof KEYS>
) {
if (typeof key === 'string') {
return ALIASES.has(key) && ALIASES.get(key) === type;
}

return key
.map((n) => {
if (n !== undefined && ALIASES.has(n) && ALIASES.get(n) === type) return true;
return false;
})
.includes(true);
}
28 changes: 22 additions & 6 deletions packages/core/src/utils.ts → packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import type { Key } from 'node:readline';

import { stdin, stdout } from 'node:process';
import type { Key } from 'node:readline';
import * as readline from 'node:readline';
import type { Readable } from 'node:stream';
import { cursor } from 'sisteransi';
import { hasAliasKey } from './aliases';

const isWindows = globalThis.process.platform.startsWith('win');

export * from './aliases';
export * from './string';

export const CANCEL_SYMBOL = Symbol('clack:cancel');

export function isCancel(value: unknown): value is symbol {
return value === CANCEL_SYMBOL;
}

export function setRawMode(input: Readable, value: boolean) {
const i = input as typeof stdin;

if (i.isTTY) i.setRawMode(value);
}

export function block({
input = stdin,
output = stdout,
Expand All @@ -21,16 +37,16 @@ export function block({
readline.emitKeypressEvents(input, rl);
if (input.isTTY) input.setRawMode(true);

const clear = (data: Buffer, { name }: Key) => {
const clear = (data: Buffer, { name, sequence }: Key) => {
const str = String(data);
if (str === '\x03') {
if (hasAliasKey([str, name, sequence], 'cancel')) {
if (hideCursor) output.write(cursor.show);
process.exit(0);
return;
}
if (!overwrite) return;
let dx = name === 'return' ? 0 : -1;
let dy = name === 'return' ? -1 : 0;
const dx = name === 'return' ? 0 : -1;
const dy = name === 'return' ? -1 : 0;

readline.moveCursor(output, dx, dy, () => {
readline.clearLine(output, 1, () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/utils/string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function diffLines(a: string, b: string) {
if (a === b) return;

const aLines = a.split('\n');
const bLines = b.split('\n');
const diff: number[] = [];

for (let i = 0; i < Math.max(aLines.length, bLines.length); i++) {
if (aLines[i] !== bLines[i]) diff.push(i);
}

return diff;
}
Loading

0 comments on commit 2770eda

Please sign in to comment.