Skip to content

Commit

Permalink
Feat: Add support for loop: false in checkbox and select (#1289)
Browse files Browse the repository at this point in the history
  • Loading branch information
artfuldev authored Oct 3, 2023
1 parent 985b937 commit 8c40778
Show file tree
Hide file tree
Showing 12 changed files with 697 additions and 98 deletions.
162 changes: 162 additions & 0 deletions packages/checkbox/checkbox.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,168 @@ describe('checkbox prompt', () => {
await expect(answer).resolves.toEqual([2, 3]);
});

it('does not scroll up beyond first item when not looping', async () => {
const { answer, events, getScreen } = await render(checkbox, {
message: 'Select a number',
choices: numberedChoices,
loop: false,
});

expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number (Press <space> to select, <a> to toggle all, <i> to invert
selection, and <enter> to proceed)
❯◯ 1
◯ 2
◯ 3
◯ 4
◯ 5
◯ 6
◯ 7
(Use arrow keys to reveal more choices)"
`);

events.keypress('up');
events.keypress('space');
expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number
❯◉ 1
◯ 2
◯ 3
◯ 4
◯ 5
◯ 6
◯ 7
(Use arrow keys to reveal more choices)"
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 1"');

await expect(answer).resolves.toEqual([1]);
});

it('does not scroll up beyond first selectable item when not looping', async () => {
const { answer, events, getScreen } = await render(checkbox, {
message: 'Select a number',
choices: [new Separator(), ...numberedChoices],
loop: false,
});

expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number (Press <space> to select, <a> to toggle all, <i> to invert
selection, and <enter> to proceed)
──────────────
❯◯ 1
◯ 2
◯ 3
◯ 4
◯ 5
◯ 6
(Use arrow keys to reveal more choices)"
`);

events.keypress('up');
events.keypress('space');
expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number
──────────────
❯◉ 1
◯ 2
◯ 3
◯ 4
◯ 5
◯ 6
(Use arrow keys to reveal more choices)"
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 1"');

await expect(answer).resolves.toEqual([1]);
});

it('does not scroll down beyond last option when not looping', async () => {
const { answer, events, getScreen } = await render(checkbox, {
message: 'Select a number',
choices: numberedChoices,
loop: false,
});

expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number (Press <space> to select, <a> to toggle all, <i> to invert
selection, and <enter> to proceed)
❯◯ 1
◯ 2
◯ 3
◯ 4
◯ 5
◯ 6
◯ 7
(Use arrow keys to reveal more choices)"
`);

numberedChoices.forEach(() => events.keypress('down'));
events.keypress('down');
events.keypress('space');
expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number
◯ 6
◯ 7
◯ 8
◯ 9
◯ 10
◯ 11
❯◉ 12
(Use arrow keys to reveal more choices)"
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 12"');

await expect(answer).resolves.toEqual([12]);
});

it('does not scroll down beyond last selectable option when not looping', async () => {
const { answer, events, getScreen } = await render(checkbox, {
message: 'Select a number',
choices: [...numberedChoices, new Separator()],
loop: false,
});

expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number (Press <space> to select, <a> to toggle all, <i> to invert
selection, and <enter> to proceed)
❯◯ 1
◯ 2
◯ 3
◯ 4
◯ 5
◯ 6
◯ 7
(Use arrow keys to reveal more choices)"
`);

numberedChoices.forEach(() => events.keypress('down'));
events.keypress('down');
events.keypress('space');
expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number
◯ 7
◯ 8
◯ 9
◯ 10
◯ 11
❯◉ 12
──────────────
(Use arrow keys to reveal more choices)"
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 12"');

await expect(answer).resolves.toEqual([12]);
});

it('use number key to select an option', async () => {
const { answer, events, getScreen } = await render(checkbox, {
message: 'Select a number',
Expand Down
33 changes: 22 additions & 11 deletions packages/checkbox/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
useKeypress,
usePrefix,
usePagination,
useMemo,
isUpKey,
isDownKey,
isSpaceKey,
Expand All @@ -30,6 +31,7 @@ type Config<Value> = PromptConfig<{
pageSize?: number;
instructions?: string | boolean;
choices: ReadonlyArray<Choice<Value> | Separator>;
loop?: boolean;
}>;

type Item<Value> = Separator | Choice<Value>;
Expand Down Expand Up @@ -72,26 +74,36 @@ function renderItem<Value>({ item, isActive }: { item: Item<Value>; isActive: bo

export default createPrompt(
<Value extends unknown>(config: Config<Value>, done: (value: Array<Value>) => void) => {
const { prefix = usePrefix(), instructions, pageSize, choices } = config;
const { prefix = usePrefix(), instructions, pageSize, loop = true, choices } = config;
const [status, setStatus] = useState('pending');
const [items, setItems] = useState<ReadonlyArray<Item<Value>>>(
choices.map((choice) => ({ ...choice })),
);
const [active, setActive] = useState<number>(() => {
const selected = items.findIndex(isSelectable);
if (selected < 0)

const bounds = useMemo(() => {
const first = items.findIndex(isSelectable);
// TODO: Replace with `findLastIndex` when it's available.

Check warning on line 85 in packages/checkbox/src/index.mts

View workflow job for this annotation

GitHub Actions / Linting

Unexpected 'todo' comment: 'TODO: Replace with `findLastIndex` when...'
const last = items.length - 1 - [...items].reverse().findIndex(isSelectable);

if (first < 0) {
throw new Error(
'[checkbox prompt] No selectable choices. All choices are disabled.',
);
return selected;
});
}

return { first, last };
}, [items]);

const [active, setActive] = useState(bounds.first);
const [showHelpTip, setShowHelpTip] = useState(true);

useKeypress((key) => {
if (isEnterKey(key)) {
setStatus('done');
done(items.filter(isChecked).map((choice) => choice.value));
} else if (isUpKey(key) || isDownKey(key)) {
if (!loop && active === bounds.first && isUpKey(key)) return;
if (!loop && active === bounds.last && isDownKey(key)) return;
const offset = isUpKey(key) ? -1 : 1;
let next = active;
do {
Expand Down Expand Up @@ -120,13 +132,12 @@ export default createPrompt(

const message = chalk.bold(config.message);

const lines = items
.map((item, index) => renderItem({ item, isActive: index === active }))
.join('\n');

const page = usePagination(lines, {
const page = usePagination<Item<Value>>({
items,
active,
renderItem,
pageSize,
loop,
});

if (status === 'done') {
Expand Down
15 changes: 10 additions & 5 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,20 +125,25 @@ Listening for keypress events inside an inquirer prompt is a very common pattern

### `usePagination`

When looping through a long list of options (like in the `select` prompt), paginating the results appearing on the screen at once can be necessary. The `usePagination` hook is the utility used within the `select` and `checkbox` prompt to cycle through the list of options.
When looping through a long list of options (like in the `select` prompt), paginating the results appearing on the screen at once can be necessary. The `usePagination` hook is the utility used within the `select` and `checkbox` prompts to cycle through the list of options.

Pagination works by taking in the list of options and returning a subset of the rendered items that fit within the page. The hook takes in a few options. It needs a list of options (`items`), and a `pageSize` which is the number of lines to be rendered. The `active` index is the index of the currently selected/selectable item. The `loop` option is a boolean that indicates if the list should loop around when reaching the end: this is the default behavior. The pagination hook renders items only as necessary, so it takes a function that can render an item at an index, including an `active` state, called `renderItem`.

```js
export default createPrompt((config, done) => {
const [cursorPosition, setCursorPosition] = useState(0);
const [active, setActive] = useState(0);

const allChoices = config.choices.map((choice) => choice.name);

const windowedChoices = usePagination(allChoices, {
active: cursorPosition,
const page = usePagination({
items: allChoices,
active: active,
renderItem: ({ item, index, isActive }) => `${isActive ? ">" : " "}${index}. ${item.toString()}`
pageSize: config.pageSize,
loop: config.loop,
});

return `... ${windowedChoices}`;
return `... ${page}`;
});
```

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export { useEffect } from './lib/use-effect.mjs';
export { useMemo } from './lib/use-memo.mjs';
export { useRef } from './lib/use-ref.mjs';
export { useKeypress } from './lib/use-keypress.mjs';
export { usePagination } from './lib/use-pagination.mjs';
export { usePagination } from './lib/pagination/use-pagination.mjs';
export {
createPrompt,
type PromptConfig,
Expand Down
94 changes: 94 additions & 0 deletions packages/core/src/lib/pagination/lines.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { type Prettify } from '@inquirer/type';
import { breakLines } from '../utils.mjs';

/** Represents an item that's part of a layout, about to be rendered */
export type Layout<T> = {
item: T;
index: number;
isActive: boolean;
};

function split(content: string, width: number) {
return breakLines(content, width).split('\n');
}

/**
* Rotates an array of items by an integer number of positions.
* @param {number} count The number of positions to rotate by
* @param {T[]} items The items to rotate
*/
function rotate<T>(count: number, items: readonly T[]): readonly T[] {
const max = items.length;
const offset = ((count % max) + max) % max;
return items.slice(offset).concat(items.slice(0, offset));
}

/**
* Renders a page of items as lines that fit within the given width ensuring
* that the number of lines is not greater than the page size, and the active
* item renders at the provided position, while prioritizing that as many lines
* of the active item get rendered as possible.
*/
export function lines<T>({
items,
width,
renderItem,
active,
position: requested,
pageSize,
}: {
items: readonly T[];
/** The width of a rendered line in characters. */
width: number;
/** Renders an item as part of a page. */
renderItem: (layout: Prettify<Layout<T>>) => string;
/** The index of the active item in the list of items. */
active: number;
/** The position on the page at which to render the active item. */
position: number;
/** The number of lines to render per page. */
pageSize: number;
}): string[] {
const layouts = items.map<Layout<T>>((item, index) => ({
item,
index,
isActive: index === active,
}));
const layoutsInPage = rotate(active - requested, layouts).slice(0, pageSize);
const renderItemAt = (index: number) => split(renderItem(layoutsInPage[index]!), width);

// Create a blank array of lines for the page
const pageBuffer = new Array(pageSize);

// Render the active item to decide the position
const activeItem = renderItemAt(requested).slice(0, pageSize);
const position =
requested + activeItem.length <= pageSize ? requested : pageSize - activeItem.length;

// Add the lines of the active item into the page
pageBuffer.splice(position, activeItem.length, ...activeItem);

// Fill the page under the active item
let bufferPointer = position + activeItem.length;
let layoutPointer = requested + 1;
while (bufferPointer < pageSize && layoutPointer < layoutsInPage.length) {
for (const line of renderItemAt(layoutPointer)) {
pageBuffer[bufferPointer++] = line;
if (bufferPointer >= pageSize) break;
}
layoutPointer++;
}

// Fill the page over the active item
bufferPointer = position - 1;
layoutPointer = requested - 1;
while (bufferPointer >= 0 && layoutPointer >= 0) {
for (const line of renderItemAt(layoutPointer).reverse()) {
pageBuffer[bufferPointer--] = line;
if (bufferPointer < 0) break;
}
layoutPointer--;
}

return pageBuffer.filter((line) => typeof line === 'string');
}
Loading

0 comments on commit 8c40778

Please sign in to comment.