Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add support for loop: false in checkbox and select #1289

Merged
merged 101 commits into from
Oct 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
101 commits
Select commit Hold shift + click to select a range
22a51c2
Allow rotating a list of items
artfuldev Aug 28, 2023
437dad7
Calculate the position of an active item in a page
artfuldev Aug 28, 2023
c925c8a
Simplify
artfuldev Aug 28, 2023
6201a23
Add loop config to select and checkbox
artfuldev Aug 28, 2023
a9ed7cc
Add loop sample config
artfuldev Aug 28, 2023
ff4b507
Optimize
artfuldev Aug 28, 2023
f06bfc3
Add test for `loop: false`
artfuldev Aug 28, 2023
89fe405
Use ref over state and effect
artfuldev Aug 28, 2023
dee2db2
Add tests for `loop: false`
artfuldev Aug 29, 2023
251dd88
Allow pagination with internal navigation
artfuldev Aug 29, 2023
01731b2
Use new pagination hook
artfuldev Aug 29, 2023
fe5a0a6
Add tests for `loop: false`
artfuldev Aug 29, 2023
3f79221
Disambiguate navigation message
artfuldev Aug 29, 2023
27feef7
Simplify
artfuldev Aug 29, 2023
0bd3333
Simplify
artfuldev Aug 29, 2023
f77395f
Simplify
artfuldev Aug 29, 2023
1e94acb
Fix typescript issues
artfuldev Aug 29, 2023
9a5b431
Avoid unnecessary check
artfuldev Aug 29, 2023
43b972f
Fix imports from CommonJS modules
artfuldev Aug 29, 2023
8aa11df
Move pagination into module
artfuldev Aug 29, 2023
d764d90
Simplify selectable
artfuldev Aug 30, 2023
d551c2f
Allow working on readonly items
artfuldev Aug 30, 2023
900a2cb
Introduce a modular primitive
artfuldev Aug 30, 2023
ab8fa20
Render only items that may belong to the page
artfuldev Aug 30, 2023
5ee05df
Simplify rendering
artfuldev Aug 30, 2023
585c01e
Prefer function to hook
artfuldev Aug 30, 2023
8bb3b30
Integrate speed dial into navigation
artfuldev Aug 30, 2023
4e28822
Remove unnecessary imports
artfuldev Aug 30, 2023
d2d5cb6
Export index
artfuldev Aug 30, 2023
d999484
Simplify
artfuldev Aug 30, 2023
6f6085c
Simplify
artfuldev Aug 30, 2023
603049a
Extract navigation hooks and use composition
artfuldev Aug 30, 2023
8a6605a
Add some help text
artfuldev Aug 30, 2023
03af08d
Simplify
artfuldev Aug 30, 2023
e315475
Add some help text
artfuldev Aug 30, 2023
b73a2a3
Fix wrap-around
artfuldev Aug 30, 2023
dca57b0
Add some specifications for multi-line items
artfuldev Sep 1, 2023
4e57791
Activate the first selectable option by default
artfuldev Sep 1, 2023
8009890
Merge from 'master'
artfuldev Sep 4, 2023
0775e0a
Dumb down lines.test.mts
SBoudrias Sep 4, 2023
c08ade9
Rename Paged<T> to Layout<T>
artfuldev Sep 4, 2023
cf14edb
Use slice
artfuldev Sep 4, 2023
76476c9
Make active mandatory
artfuldev Sep 4, 2023
4040494
Simplify
artfuldev Sep 4, 2023
23b7b6f
Fix expectation
artfuldev Sep 4, 2023
e766082
Render as many lines of active item as possible
artfuldev Sep 4, 2023
1b59e23
Add help text
artfuldev Sep 4, 2023
eb726b3
Add note on rendering most lines of active item
artfuldev Sep 4, 2023
133f2eb
Merge remote-tracking branch 'upstream/master' into support-not-loopi…
artfuldev Sep 4, 2023
f982175
Fix lint issues
artfuldev Sep 4, 2023
ee7ad49
Remove additional utility
artfuldev Sep 5, 2023
6188e30
Update internals
artfuldev Sep 5, 2023
bff847e
Render only as much as required
artfuldev Sep 5, 2023
5e382bb
Fix eslint error
artfuldev Sep 5, 2023
21bd908
Add a moot check
artfuldev Sep 5, 2023
b497794
Silence type check failures
artfuldev Sep 5, 2023
db79889
Remove unnecessary slice
artfuldev Sep 6, 2023
3b07966
Use a small helper to render lines
artfuldev Sep 6, 2023
62869d8
Reduce active lines to page size
artfuldev Sep 6, 2023
087141b
Uncurry
artfuldev Sep 9, 2023
c01d872
Use single file per component
artfuldev Sep 9, 2023
ebbc609
Inline types
artfuldev Sep 9, 2023
7eceaa9
Use ifs over nested ternary
artfuldev Sep 9, 2023
6c75f1c
Fix imports
artfuldev Sep 9, 2023
291c094
Export layout type
artfuldev Sep 9, 2023
7a5c54b
Remove active state management from pagination
artfuldev Sep 9, 2023
4b4c138
Export choice types
artfuldev Sep 9, 2023
618ffc9
Remove use-scroll
artfuldev Sep 9, 2023
6c38df0
Remove use-speed-dial
artfuldev Sep 9, 2023
ac3ca3e
Reorder imports
artfuldev Sep 9, 2023
5834d89
Reorder cases
artfuldev Sep 9, 2023
8f76169
Reorder cases
artfuldev Sep 9, 2023
0db9779
Move conditions
artfuldev Sep 9, 2023
c0cd080
Reorder imports
artfuldev Sep 9, 2023
c75a1ed
Move pagination
artfuldev Sep 9, 2023
9a3556a
Merge remote-tracking branch 'upstream/master' into support-not-loopi…
artfuldev Sep 10, 2023
15153b3
Inline render type
artfuldev Sep 10, 2023
f74b780
Stop exporting layout type
artfuldev Sep 10, 2023
6017d60
Inline index calculation
artfuldev Sep 10, 2023
2b3a8d0
Stop exporting index
artfuldev Sep 10, 2023
f43eb7b
Merge remote-tracking branch 'upstream/master' into support-not-loopi…
artfuldev Sep 10, 2023
3639f5c
Stop exporting index
artfuldev Sep 10, 2023
9f6b632
Exclude test files from compilation
artfuldev Sep 10, 2023
14ac65f
Update packages/select/src/index.mts
SBoudrias Sep 16, 2023
a6a6b75
Merge branch 'master' into support-not-looping-over-lists
SBoudrias Sep 16, 2023
40c2a97
Update packages/core/src/index.mts
SBoudrias Sep 16, 2023
04af3d6
Nits
SBoudrias Sep 16, 2023
ace3a2f
Fix doc
artfuldev Sep 16, 2023
a6a4643
Prettify layout type for erasure
artfuldev Sep 16, 2023
52fdc3c
Rewrite docs
artfuldev Sep 16, 2023
7e1c9a3
Fix typo
artfuldev Sep 16, 2023
bd78214
Remove unecessary file
SBoudrias Sep 24, 2023
f193021
Inlining, renames and 'dumbify' the code
SBoudrias Sep 24, 2023
68fe1f5
Merge branch 'master' into support-not-looping-over-lists
SBoudrias Sep 24, 2023
d7679b7
Remove unecessary tests (erroneously commited)
SBoudrias Sep 24, 2023
c497e39
Add tests around selectable items
artfuldev Sep 29, 2023
6ec6c87
Disallow scrolling past selectable items
artfuldev Sep 29, 2023
5346983
Merge remote-tracking branch 'origin/master' into support-not-looping…
SBoudrias Sep 29, 2023
647d5c9
Checkbox/Select with memoized bounds instead of state
SBoudrias Sep 29, 2023
cd8a7df
Replace findLast* by Node 16 compat code
SBoudrias Sep 29, 2023
5b085f6
Add TODO reminders for the ugly code
SBoudrias Oct 3, 2023
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
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 @@
useKeypress,
usePrefix,
usePagination,
useMemo,
isUpKey,
isDownKey,
isSpaceKey,
Expand All @@ -30,6 +31,7 @@
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 @@

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);
SBoudrias marked this conversation as resolved.
Show resolved Hide resolved

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 @@

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,
SBoudrias marked this conversation as resolved.
Show resolved Hide resolved
});

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
Loading