From 22a51c2e337db42dce0f009f44ff2c815a6703c3 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Mon, 28 Aug 2023 14:09:49 +0100 Subject: [PATCH 01/94] Allow rotating a list of items --- packages/core/src/lib/utils.mts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/core/src/lib/utils.mts b/packages/core/src/lib/utils.mts index 37e55339d..c5c50d5c1 100644 --- a/packages/core/src/lib/utils.mts +++ b/packages/core/src/lib/utils.mts @@ -16,3 +16,18 @@ export const breakLines = (content: string, width: number): string => .map((line) => line.trimEnd()), ) .join('\n'); + +/** + * Creates a 0-based index out of an integer, wrapping around if necessary. + */ +const index = (max: number) => (value: number) => ((value % max) + max) % max; + +/** + * Rotates an array of items by an integer number of positions. + */ +export const rotate = + (count: number) => + (items: T[]): T[] => { + const offset = index(items.length)(count); + return items.slice(offset).concat(items.slice(0, offset)); + }; From 437dad70e15369134b3d3ec142c63dbc13312db1 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Mon, 28 Aug 2023 14:10:11 +0100 Subject: [PATCH 02/94] Calculate the position of an active item in a page --- packages/core/src/lib/position.mts | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 packages/core/src/lib/position.mts diff --git a/packages/core/src/lib/position.mts b/packages/core/src/lib/position.mts new file mode 100644 index 000000000..ab66594f0 --- /dev/null +++ b/packages/core/src/lib/position.mts @@ -0,0 +1,49 @@ +type Change = { + previous: T; + current: T; +}; + +type PageInfo = { + active: Change; + total: number; + pageSize: number; +}; + +/** + * Given information about a page, decides the next position at which the active + * item should be rendered in the page. + */ +type PositionReducer = (info: PageInfo) => (pointer: number) => number; + +/** + * Creates the next position for the active item considering a finite list of + * items to be rendered on a page. + */ +export const finite: PositionReducer = + ({ pageSize, total, active }) => + () => { + const middle = Math.floor(pageSize / 2); + return total <= pageSize || active.current < middle + ? active.current + : active.current >= total - middle + ? active.current + pageSize - total + : middle; + }; + +/** + * Creates the next position for the active item considering an infinitely + * looping list of items to be rendered on the page. + */ +export const infinite: PositionReducer = + ({ active, total, pageSize }) => + (pointer) => + total <= pageSize + ? finite({ active, total, pageSize })(pointer) + : /** + * Move the position only when the user moves down, and when the + * navigation fits within a single page + */ + active.previous < active.current && active.current - active.previous < pageSize + ? // Limit it to the middle of the list + Math.min(Math.floor(pageSize / 2), pointer + active.current - active.previous) + : pointer; From c925c8a9a873c69e4cb4c9e4477068c1828a84ca Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Mon, 28 Aug 2023 14:11:11 +0100 Subject: [PATCH 03/94] Simplify --- packages/core/src/index.mts | 55 +++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index 708eae125..7b8778485 100644 --- a/packages/core/src/index.mts +++ b/packages/core/src/index.mts @@ -6,7 +6,8 @@ import cliWidth from 'cli-width'; import MuteStream from 'mute-stream'; import ScreenManager from './lib/screen-manager.mjs'; import { getPromptConfig } from './lib/options.mjs'; -import { breakLines } from './lib/utils.mjs'; +import { breakLines, rotate } from './lib/utils.mjs'; +import { finite, infinite } from './lib/position.mjs'; export { usePrefix } from './lib/prefix.mjs'; export * from './lib/key.mjs'; @@ -181,41 +182,43 @@ export function usePagination( { active, pageSize = 7, + loop = true, }: { active: number; pageSize?: number; + loop?: boolean; }, ) { const { rl } = context.getStore(); - const state = useRef({ - pointer: 0, - lastIndex: 0, - }); const width = cliWidth({ defaultWidth: 80, output: rl.output }); const lines = breakLines(output, width).split('\n'); - // Make sure there's enough lines to paginate - if (lines.length <= pageSize) { - return output; - } - - const middleOfList = Math.floor(pageSize / 2); - - // Move the pointer only when the user go down and limit it to the middle of the list - const { pointer: prevPointer, lastIndex } = state.current; - if (prevPointer < middleOfList && lastIndex < active && active - lastIndex < pageSize) { - state.current.pointer = Math.min(middleOfList, prevPointer + active - lastIndex); - } - - state.current.lastIndex = active; - - // Duplicate the lines so it give an infinite list look - const infinite = [lines, lines, lines].flat(); - const topIndex = Math.max(0, active + lines.length - state.current.pointer); - - const section = infinite.splice(topIndex, pageSize).join('\n'); - return section + '\n' + chalk.dim('(Move up and down to reveal more choices)'); + const [lastActive, setLastActive] = useState(active); + useEffect(() => { + setLastActive(active); + }, [active, setLastActive]); + + const [position, setPosition] = useState(0); + useEffect(() => { + setPosition( + (loop ? infinite : finite)({ + active: { current: active, previous: lastActive }, + total: lines.length, + pageSize, + })(position), + ); + }, [loop, active, pageSize, setPosition, position, lastActive, lines.length]); + + // Rotate lines such that the active index is at the specified position + return rotate(active - position)(lines) + .slice(0, pageSize) + .concat( + lines.length <= pageSize + ? [] + : [chalk.dim('(Move up and down to reveal more choices)')], + ) + .join('\n'); } export type AsyncPromptConfig = { From 6201a23313e1d81309cf7eb7c22cb125b2a8b04c Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Mon, 28 Aug 2023 14:11:56 +0100 Subject: [PATCH 04/94] Add loop config to select and checkbox --- packages/checkbox/src/index.mts | 2 ++ packages/select/src/index.mts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 7c90c63eb..d131d9cd9 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -30,6 +30,7 @@ type Config = { instructions?: string | boolean; message: string; choices: ReadonlyArray | Separator>; + loop?: boolean; }; function isSelectableChoice( @@ -147,6 +148,7 @@ export default createPrompt( const windowedChoices = usePagination(allChoices, { active: cursorPosition, pageSize: config.pageSize, + loop: config.loop, }); if (status === 'done') { diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 181e8402b..e2ef5d13b 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -28,6 +28,7 @@ type Choice = { type SelectConfig = AsyncPromptConfig & { choices: ReadonlyArray | Separator>; pageSize?: number; + loop?: boolean; }; function isSelectableChoice( @@ -118,6 +119,7 @@ export default createPrompt( const windowedChoices = usePagination(allChoices, { active: cursorPosition, pageSize: config.pageSize, + loop: config.loop, }); if (status === 'done') { From a9ed7ccb5cb2aa3c5bf728cd4e05a8ffefdbb694 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Mon, 28 Aug 2023 14:12:15 +0100 Subject: [PATCH 05/94] Add loop sample config --- packages/core/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/README.md b/packages/core/README.md index f2b0ec2b7..b0df50a46 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -135,6 +135,7 @@ export default createPrompt((config, done) => { const windowedChoices = usePagination(allChoices, { active: cursorPosition, pageSize: config.pageSize, + loop: config.loop, }); return `... ${windowedChoices}`; From ff4b507684238c78eaba6e5e94df1bcd0a4e6f78 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Mon, 28 Aug 2023 14:13:27 +0100 Subject: [PATCH 06/94] Optimize --- packages/core/src/lib/position.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/lib/position.mts b/packages/core/src/lib/position.mts index ab66594f0..551bcbc95 100644 --- a/packages/core/src/lib/position.mts +++ b/packages/core/src/lib/position.mts @@ -38,7 +38,7 @@ export const infinite: PositionReducer = ({ active, total, pageSize }) => (pointer) => total <= pageSize - ? finite({ active, total, pageSize })(pointer) + ? active.current : /** * Move the position only when the user moves down, and when the * navigation fits within a single page From f06bfc3ef174620598fe2863841c87305370018b Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Mon, 28 Aug 2023 14:27:55 +0100 Subject: [PATCH 07/94] Add test for `loop: false` --- packages/select/select.test.mts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/select/select.test.mts b/packages/select/select.test.mts index 325ea06be..b5d7953d5 100644 --- a/packages/select/select.test.mts +++ b/packages/select/select.test.mts @@ -152,6 +152,34 @@ describe('select prompt', () => { await expect(answer).resolves.toEqual(11); }); + it('stays at top if not looping', async () => { + const { answer, events, getScreen } = await render(select, { + message: 'Select a number', + choices: numberedChoices, + pageSize: 2, + loop: false, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Use arrow keys) + ❯ 1 + 2 + (Move up and down to reveal more choices)" + `); + + events.keypress('up'); + events.keypress('up'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + ❯ 1 + 2 + (Move up and down to reveal more choices)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual(11); + }); + it('skip disabled options by arrow keys', async () => { const { answer, events, getScreen } = await render(select, { message: 'Select a topping', From 89fe4059f2098cde7a2a9b6048161af406c088d6 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 29 Aug 2023 00:08:13 +0100 Subject: [PATCH 08/94] Use ref over state and effect --- packages/core/src/index.mts | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index 7b8778485..01d5076e7 100644 --- a/packages/core/src/index.mts +++ b/packages/core/src/index.mts @@ -193,25 +193,21 @@ export function usePagination( const width = cliWidth({ defaultWidth: 80, output: rl.output }); const lines = breakLines(output, width).split('\n'); + const state = useRef({ + position: 0, + lastActive: 0, + }); + const { lastActive, position } = state.current; - const [lastActive, setLastActive] = useState(active); - useEffect(() => { - setLastActive(active); - }, [active, setLastActive]); - - const [position, setPosition] = useState(0); - useEffect(() => { - setPosition( - (loop ? infinite : finite)({ - active: { current: active, previous: lastActive }, - total: lines.length, - pageSize, - })(position), - ); - }, [loop, active, pageSize, setPosition, position, lastActive, lines.length]); + state.current.position = (loop ? infinite : finite)({ + active: { current: active, previous: lastActive }, + total: lines.length, + pageSize, + })(position); + state.current.lastActive = active; - // Rotate lines such that the active index is at the specified position - return rotate(active - position)(lines) + // Rotate lines such that the active index is at the current position + return rotate(active - state.current.position)(lines) .slice(0, pageSize) .concat( lines.length <= pageSize From dee2db222d7bd1a3028e36774c53d32cfd0f4911 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 29 Aug 2023 20:51:18 +0100 Subject: [PATCH 09/94] Add tests for `loop: false` --- packages/select/select.test.mts | 34 ++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/select/select.test.mts b/packages/select/select.test.mts index b5d7953d5..23aed402f 100644 --- a/packages/select/select.test.mts +++ b/packages/select/select.test.mts @@ -152,7 +152,7 @@ describe('select prompt', () => { await expect(answer).resolves.toEqual(11); }); - it('stays at top if not looping', async () => { + it('does not scroll up beyond first item when not looping', async () => { const { answer, events, getScreen } = await render(select, { message: 'Select a number', choices: numberedChoices, @@ -170,14 +170,42 @@ describe('select prompt', () => { events.keypress('up'); events.keypress('up'); expect(getScreen()).toMatchInlineSnapshot(` - "? Select a number + "? Select a number (Use arrow keys) ❯ 1 2 (Move up and down to reveal more choices)" `); events.keypress('enter'); - await expect(answer).resolves.toEqual(11); + await expect(answer).resolves.toEqual(1); + }); + + it('does not scroll down beyond last item when not looping', async () => { + const { answer, events, getScreen } = await render(select, { + message: 'Select a number', + choices: numberedChoices, + pageSize: 2, + loop: false, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Use arrow keys) + ❯ 1 + 2 + (Move up and down to reveal more choices)" + `); + + numberedChoices.forEach(() => events.keypress('down')); + events.keypress('down'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + 11 + ❯ 12 + (Move up and down to reveal more choices)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual(numberedChoices.length); }); it('skip disabled options by arrow keys', async () => { From 251dd88941769349ae1923a57a4ef3524bf53f62 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 29 Aug 2023 20:53:51 +0100 Subject: [PATCH 10/94] Allow pagination with internal navigation --- packages/core/src/index.mts | 47 +--------------- packages/core/src/lib/pagination.mts | 80 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 45 deletions(-) create mode 100644 packages/core/src/lib/pagination.mts diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index 01d5076e7..b4ad948d2 100644 --- a/packages/core/src/index.mts +++ b/packages/core/src/index.mts @@ -1,17 +1,14 @@ import * as readline from 'node:readline'; import { AsyncLocalStorage, AsyncResource } from 'node:async_hooks'; import { CancelablePromise, type Prompt } from '@inquirer/type'; -import chalk from 'chalk'; -import cliWidth from 'cli-width'; import MuteStream from 'mute-stream'; import ScreenManager from './lib/screen-manager.mjs'; import { getPromptConfig } from './lib/options.mjs'; -import { breakLines, rotate } from './lib/utils.mjs'; -import { finite, infinite } from './lib/position.mjs'; export { usePrefix } from './lib/prefix.mjs'; export * from './lib/key.mjs'; export * from './lib/Separator.mjs'; +export * from './lib/pagination.mjs'; export type InquirerReadline = readline.ReadLine & { output: MuteStream; @@ -37,7 +34,7 @@ type HookStore = { const hookStorage = new AsyncLocalStorage(); -const context = { +export const context = { getStore() { const store = hookStorage.getStore(); if (!store) { @@ -177,46 +174,6 @@ export function useKeypress( }, []); } -export function usePagination( - output: string, - { - active, - pageSize = 7, - loop = true, - }: { - active: number; - pageSize?: number; - loop?: boolean; - }, -) { - const { rl } = context.getStore(); - - const width = cliWidth({ defaultWidth: 80, output: rl.output }); - const lines = breakLines(output, width).split('\n'); - const state = useRef({ - position: 0, - lastActive: 0, - }); - const { lastActive, position } = state.current; - - state.current.position = (loop ? infinite : finite)({ - active: { current: active, previous: lastActive }, - total: lines.length, - pageSize, - })(position); - state.current.lastActive = active; - - // Rotate lines such that the active index is at the current position - return rotate(active - state.current.position)(lines) - .slice(0, pageSize) - .concat( - lines.length <= pageSize - ? [] - : [chalk.dim('(Move up and down to reveal more choices)')], - ) - .join('\n'); -} - export type AsyncPromptConfig = { message: string | Promise | (() => Promise); validate?: (value: string) => boolean | string | Promise; diff --git a/packages/core/src/lib/pagination.mts b/packages/core/src/lib/pagination.mts new file mode 100644 index 000000000..206887491 --- /dev/null +++ b/packages/core/src/lib/pagination.mts @@ -0,0 +1,80 @@ +import chalk from 'chalk'; +import { context, useState, useRef, useKeypress, isUpKey, isDownKey } from '../index.mjs'; +import cliWidth from 'cli-width'; +import { breakLines, rotate } from './utils.mjs'; +import { finite, infinite } from './position.mjs'; + +type F = (...args: A) => B; +type UnaryF = F<[T], R>; +type Action = UnaryF; + +export type Paged = { + item: T; + active: number; + index: number; +}; + +type Options = { + items: readonly T[]; + selectable: UnaryF, boolean>; + render: UnaryF, string>; + pageSize?: number; + loop?: boolean; +}; + +type Page = { + contents: string; + active: number; + setActive: Action; +}; + +export function usePagination({ + items, + selectable, + render, + pageSize = 7, + loop = true, +}: Options): Page { + const { rl } = context.getStore(); + const state = useRef({ + position: 0, + lastActive: 0, + }); + const [active, setActive] = useState(0); + useKeypress((key) => { + if ( + !loop && + ((active === 0 && isUpKey(key)) || (active === items.length - 1 && isDownKey(key))) + ) + return; + if (isUpKey(key) || isDownKey(key)) { + const offset = isUpKey(key) ? -1 : 1; + let next = (active + items.length + offset) % items.length; + while (!selectable({ item: items[next]!, index: next, active })) { + next = (next + items.length + offset) % items.length; + } + if (next === active) return; + setActive(next); + } + }); + const width = cliWidth({ defaultWidth: 80, output: rl.output }); + const output = items.map((item, index) => render({ item, index, active })).join('\n'); + const lines = breakLines(output, width).split('\n'); + state.current.position = (loop ? infinite : finite)({ + active: { current: active, previous: state.current.lastActive }, + total: lines.length, + pageSize, + })(state.current.position); + state.current.lastActive = active; + + // Rotate lines such that the active index is at the current position + const contents = rotate(active - state.current.position)(lines) + .slice(0, pageSize) + .concat( + lines.length <= pageSize + ? [] + : [chalk.dim('(Move up and down to reveal more choices)')], + ) + .join('\n'); + return { contents, active, setActive }; +} From 01731b2e351bf348fcdb58ae67e6242cae2e51e5 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 29 Aug 2023 20:54:26 +0100 Subject: [PATCH 11/94] Use new pagination hook --- packages/checkbox/src/choice.type.mts | 7 +++ packages/checkbox/src/index.mts | 69 ++++++------------------- packages/checkbox/src/item.type.mts | 4 ++ packages/checkbox/src/render.mts | 23 +++++++++ packages/checkbox/src/selectable.mts | 6 +++ packages/select/src/choice.type.mts | 7 +++ packages/select/src/index.mts | 72 +++++++-------------------- packages/select/src/item.type.mts | 4 ++ packages/select/src/render.mts | 21 ++++++++ packages/select/src/selectable.mts | 6 +++ 10 files changed, 110 insertions(+), 109 deletions(-) create mode 100644 packages/checkbox/src/choice.type.mts create mode 100644 packages/checkbox/src/item.type.mts create mode 100644 packages/checkbox/src/render.mts create mode 100644 packages/checkbox/src/selectable.mts create mode 100644 packages/select/src/choice.type.mts create mode 100644 packages/select/src/item.type.mts create mode 100644 packages/select/src/render.mts create mode 100644 packages/select/src/selectable.mts diff --git a/packages/checkbox/src/choice.type.mts b/packages/checkbox/src/choice.type.mts new file mode 100644 index 000000000..43855fa7f --- /dev/null +++ b/packages/checkbox/src/choice.type.mts @@ -0,0 +1,7 @@ +export type Choice = { + name?: string; + value: Value; + disabled?: boolean | string; + checked?: boolean; + type?: never; +}; diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index d131d9cd9..4a97a9536 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -4,6 +4,7 @@ import { useKeypress, usePrefix, usePagination, + Paged, isUpKey, isDownKey, isSpaceKey, @@ -15,14 +16,9 @@ import type {} from '@inquirer/type'; import chalk from 'chalk'; import figures from 'figures'; import ansiEscapes from 'ansi-escapes'; - -export type Choice = { - name?: string; - value: Value; - disabled?: boolean | string; - checked?: boolean; - type?: never; -}; +import { Choice, render } from './render.mjs'; +import { selectable } from './selectable.mjs'; +import { Item } from './item.type.mjs'; type Config = { prefix?: string; @@ -50,11 +46,17 @@ export default createPrompt( const [choices, setChoices] = useState>>(() => config.choices.map((choice) => ({ ...choice })), ); - const [cursorPosition, setCursorPosition] = useState(0); const [showHelpTip, setShowHelpTip] = useState(true); + const message = chalk.bold(config.message); + const { contents, active, setActive } = usePagination>({ + items: choices, + render, + selectable: ({ item }) => selectable(item), + pageSize: config.pageSize, + loop: config.loop, + }); useKeypress((key) => { - let newCursorPosition = cursorPosition; if (isEnterKey(key)) { setStatus('done'); done( @@ -62,22 +64,11 @@ export default createPrompt( .filter((choice) => isSelectableChoice(choice) && choice.checked) .map((choice) => (choice as Choice).value), ); - } else if (isUpKey(key) || isDownKey(key)) { - const offset = isUpKey(key) ? -1 : 1; - let selectedOption; - - while (!isSelectableChoice(selectedOption)) { - newCursorPosition = - (newCursorPosition + offset + choices.length) % choices.length; - selectedOption = choices[newCursorPosition]; - } - - setCursorPosition(newCursorPosition); } else if (isSpaceKey(key)) { setShowHelpTip(false); setChoices( choices.map((choice, i) => { - if (i === cursorPosition && isSelectableChoice(choice)) { + if (i === active && isSelectableChoice(choice)) { return { ...choice, checked: !choice.checked }; } @@ -108,7 +99,7 @@ export default createPrompt( return; } - setCursorPosition(position); + setActive(position); setChoices( choices.map((choice, i) => { if (i === position && isSelectableChoice(choice)) { @@ -121,36 +112,6 @@ export default createPrompt( } }); - const message = chalk.bold(config.message); - const allChoices = choices - .map((choice, index) => { - if (Separator.isSeparator(choice)) { - return ` ${choice.separator}`; - } - - const line = choice.name || choice.value; - if (choice.disabled) { - const disabledLabel = - typeof choice.disabled === 'string' ? choice.disabled : '(disabled)'; - return chalk.dim(`- ${line} ${disabledLabel}`); - } - - const checkbox = choice.checked - ? chalk.green(figures.circleFilled) - : figures.circle; - if (index === cursorPosition) { - return chalk.cyan(`${figures.pointer}${checkbox} ${line}`); - } - - return ` ${checkbox} ${line}`; - }) - .join('\n'); - const windowedChoices = usePagination(allChoices, { - active: cursorPosition, - pageSize: config.pageSize, - loop: config.loop, - }); - if (status === 'done') { const selection = choices .filter((choice) => isSelectableChoice(choice) && choice.checked) @@ -175,7 +136,7 @@ export default createPrompt( } } - return `${prefix} ${message}${helpTip}\n${windowedChoices}${ansiEscapes.cursorHide}`; + return `${prefix} ${message}${helpTip}\n${contents}${ansiEscapes.cursorHide}`; }, ); diff --git a/packages/checkbox/src/item.type.mts b/packages/checkbox/src/item.type.mts new file mode 100644 index 000000000..2fc384b9d --- /dev/null +++ b/packages/checkbox/src/item.type.mts @@ -0,0 +1,4 @@ +import { Separator } from '@inquirer/core'; +import { Choice } from './choice.type.mjs'; + +export type Item = Separator | Choice; diff --git a/packages/checkbox/src/render.mts b/packages/checkbox/src/render.mts new file mode 100644 index 000000000..e2dab27a4 --- /dev/null +++ b/packages/checkbox/src/render.mts @@ -0,0 +1,23 @@ +import { Paged, Separator } from '@inquirer/core'; +import type {} from '@inquirer/type'; +import { dim, green, cyan } from 'chalk'; +import { circle, circleFilled, pointer } from 'figures'; +import { Item } from './item.type.mjs'; + +export const render = ({ item, index, active }: Paged>) => { + if (Separator.isSeparator(item)) { + return ` ${item.separator}`; + } + + const line = item.name || item.value; + if (item.disabled) { + const disabledLabel = + typeof item.disabled === 'string' ? item.disabled : '(disabled)'; + return dim(`- ${line} ${disabledLabel}`); + } + + const checkbox = item.checked ? green(circleFilled) : circle; + const color = index === active ? cyan : (x: string) => x; + const prefix = index === active ? pointer : ' '; + return color(`${prefix}${checkbox} ${line}`); +}; diff --git a/packages/checkbox/src/selectable.mts b/packages/checkbox/src/selectable.mts new file mode 100644 index 000000000..420e5469a --- /dev/null +++ b/packages/checkbox/src/selectable.mts @@ -0,0 +1,6 @@ +import { Choice } from './choice.type.mjs'; +import { Separator } from './index.mjs'; +import { Item } from './item.type.mjs'; + +export const selectable = (item: Item): item is Choice => + !Separator.isSeparator(item) && !item.disabled; diff --git a/packages/select/src/choice.type.mts b/packages/select/src/choice.type.mts new file mode 100644 index 000000000..29545b3bf --- /dev/null +++ b/packages/select/src/choice.type.mts @@ -0,0 +1,7 @@ +export type Choice = { + value: Value; + name?: string; + description?: string; + disabled?: boolean | string; + type?: never; +}; diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index e2ef5d13b..ec5d6688a 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -16,6 +16,9 @@ import type {} from '@inquirer/type'; import chalk from 'chalk'; import figures from 'figures'; import ansiEscapes from 'ansi-escapes'; +import { render } from './render.mjs'; +import { selectable } from './selectable.mjs'; +import { Item } from './item.type.mjs'; type Choice = { value: Value; @@ -43,50 +46,35 @@ export default createPrompt( done: (value: Value) => void, ): string => { const { choices } = config; + if (!choices.some(isSelectableChoice)) { + throw new Error('[select prompt] No selectable choices. All choices are disabled.'); + } const firstRender = useRef(true); const prefix = usePrefix(); const [status, setStatus] = useState('pending'); - const [cursorPosition, setCursorPos] = useState(() => { - const startIndex = choices.findIndex(isSelectableChoice); - if (startIndex < 0) { - throw new Error( - '[select prompt] No selectable choices. All choices are disabled.', - ); - } - - return startIndex; + const { contents, active, setActive } = usePagination>({ + items: choices, + render, + selectable: ({ item }) => selectable(item), + pageSize: config.pageSize, + loop: config.loop, }); - - // Safe to assume the cursor position always point to a Choice. - const choice = choices[cursorPosition] as Choice; - + const choice = choices[active] as Choice; useKeypress((key) => { if (isEnterKey(key)) { setStatus('done'); done(choice.value); - } else if (isUpKey(key) || isDownKey(key)) { - let newCursorPosition = cursorPosition; - const offset = isUpKey(key) ? -1 : 1; - let selectedOption; - - while (!isSelectableChoice(selectedOption)) { - newCursorPosition = - (newCursorPosition + offset + choices.length) % choices.length; - selectedOption = choices[newCursorPosition]; - } - - setCursorPos(newCursorPosition); } else if (isNumberKey(key)) { // Adjust index to start at 1 - const newCursorPosition = Number(key.name) - 1; + const position = Number(key.name) - 1; // Abort if the choice doesn't exists or if disabled - if (!isSelectableChoice(choices[newCursorPosition])) { + if (!isSelectableChoice(choices[position])) { return; } - setCursorPos(newCursorPosition); + setActive(position); } }); @@ -96,39 +84,13 @@ export default createPrompt( firstRender.current = false; } - const allChoices = choices - .map((choice, index): string => { - if (Separator.isSeparator(choice)) { - return ` ${choice.separator}`; - } - - const line = choice.name || choice.value; - if (choice.disabled) { - const disabledLabel = - typeof choice.disabled === 'string' ? choice.disabled : '(disabled)'; - return chalk.dim(`- ${line} ${disabledLabel}`); - } - - if (index === cursorPosition) { - return chalk.cyan(`${figures.pointer} ${line}`); - } - - return ` ${line}`; - }) - .join('\n'); - const windowedChoices = usePagination(allChoices, { - active: cursorPosition, - pageSize: config.pageSize, - loop: config.loop, - }); - if (status === 'done') { return `${prefix} ${message} ${chalk.cyan(choice.name || choice.value)}`; } const choiceDescription = choice.description ? `\n${choice.description}` : ``; - return `${prefix} ${message}\n${windowedChoices}${choiceDescription}${ansiEscapes.cursorHide}`; + return `${prefix} ${message}\n${contents}${choiceDescription}${ansiEscapes.cursorHide}`; }, ); diff --git a/packages/select/src/item.type.mts b/packages/select/src/item.type.mts new file mode 100644 index 000000000..2fc384b9d --- /dev/null +++ b/packages/select/src/item.type.mts @@ -0,0 +1,4 @@ +import { Separator } from '@inquirer/core'; +import { Choice } from './choice.type.mjs'; + +export type Item = Separator | Choice; diff --git a/packages/select/src/render.mts b/packages/select/src/render.mts new file mode 100644 index 000000000..d967fa926 --- /dev/null +++ b/packages/select/src/render.mts @@ -0,0 +1,21 @@ +import { Paged, Separator } from '@inquirer/core'; +import { dim, cyan } from 'chalk'; +import { pointer } from 'figures'; +import { Item } from './item.type.mjs'; + +export const render = ({ item, index, active }: Paged>) => { + if (Separator.isSeparator(item)) { + return ` ${item.separator}`; + } + + const line = item.name || item.value; + if (item.disabled) { + const disabledLabel = + typeof item.disabled === 'string' ? item.disabled : '(disabled)'; + return dim(`- ${line} ${disabledLabel}`); + } + + const color = index === active ? cyan : (x: string) => x; + const prefix = index === active ? pointer : ` `; + return color(`${prefix} ${line}`); +}; diff --git a/packages/select/src/selectable.mts b/packages/select/src/selectable.mts new file mode 100644 index 000000000..420e5469a --- /dev/null +++ b/packages/select/src/selectable.mts @@ -0,0 +1,6 @@ +import { Choice } from './choice.type.mjs'; +import { Separator } from './index.mjs'; +import { Item } from './item.type.mjs'; + +export const selectable = (item: Item): item is Choice => + !Separator.isSeparator(item) && !item.disabled; From fe5a0a6f95a2e0bd26dda97b801d0efed88a609b Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 29 Aug 2023 21:00:01 +0100 Subject: [PATCH 12/94] Add tests for `loop: false` --- packages/checkbox/checkbox.test.mts | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index 43cbf337d..d6bdbef10 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -59,6 +59,87 @@ 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 to select, to toggle all, to invert + selection, and to proceed) + ❯◯ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Move up and down to reveal more choices)" + `); + + events.keypress('up'); + events.keypress('space'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + ❯◉ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Move up and down 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 to select, to toggle all, to invert + selection, and to proceed) + ❯◯ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Move up and down 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 + (Move up and down 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', From 3f79221f34f546820412f03f50795ccae7124b51 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 29 Aug 2023 21:02:58 +0100 Subject: [PATCH 13/94] Disambiguate navigation message --- packages/checkbox/checkbox.test.mts | 36 ++++++++++++++-------------- packages/core/src/lib/pagination.mts | 2 +- packages/select/select.test.mts | 22 ++++++++--------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index d6bdbef10..d64d2d56c 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -34,7 +34,7 @@ describe('checkbox prompt', () => { ◯ 5 ◯ 6 ◯ 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('down'); @@ -50,7 +50,7 @@ describe('checkbox prompt', () => { ◯ 5 ◯ 6 ◯ 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -76,7 +76,7 @@ describe('checkbox prompt', () => { ◯ 5 ◯ 6 ◯ 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('up'); @@ -90,7 +90,7 @@ describe('checkbox prompt', () => { ◯ 5 ◯ 6 ◯ 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -116,7 +116,7 @@ describe('checkbox prompt', () => { ◯ 5 ◯ 6 ◯ 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); numberedChoices.forEach(() => events.keypress('down')); @@ -131,7 +131,7 @@ describe('checkbox prompt', () => { ◯ 10 ◯ 11 ❯◉ 12 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -157,7 +157,7 @@ describe('checkbox prompt', () => { ◯ 5 ◯ 6 ◯ 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -178,7 +178,7 @@ describe('checkbox prompt', () => { selection, and to proceed) ❯◯ 1 ◯ 2 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -205,7 +205,7 @@ describe('checkbox prompt', () => { ◯ 8 ◯ 9 ◯ 10 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -224,7 +224,7 @@ describe('checkbox prompt', () => { selection, and to proceed) ❯◯ 1 ◯ 2 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('up'); @@ -235,7 +235,7 @@ describe('checkbox prompt', () => { "? Select a number ❯◉ 11 ◉ 12 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -391,7 +391,7 @@ describe('checkbox prompt', () => { ◯ 5 ◯ 6 ◯ 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('a'); @@ -405,7 +405,7 @@ describe('checkbox prompt', () => { ◉ 5 ◉ 6 ◉ 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('a'); @@ -419,7 +419,7 @@ describe('checkbox prompt', () => { ◯ 5 ◯ 6 ◯ 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('a'); @@ -444,7 +444,7 @@ describe('checkbox prompt', () => { ◯ 5 ◯ 6 ◯ 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('a'); @@ -473,7 +473,7 @@ describe('checkbox prompt', () => { ◯ 9 ◯ 10 ◯ 11 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('i'); @@ -497,7 +497,7 @@ describe('checkbox prompt', () => { ◯ 5 ◯ 6 ◯ 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -524,7 +524,7 @@ describe('checkbox prompt', () => { ◯ 5 ◯ 6 ◯ 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); diff --git a/packages/core/src/lib/pagination.mts b/packages/core/src/lib/pagination.mts index 206887491..d893dab1c 100644 --- a/packages/core/src/lib/pagination.mts +++ b/packages/core/src/lib/pagination.mts @@ -73,7 +73,7 @@ export function usePagination({ .concat( lines.length <= pageSize ? [] - : [chalk.dim('(Move up and down to reveal more choices)')], + : [chalk.dim('(Use arrow keys to reveal more choices)')], ) .join('\n'); return { contents, active, setActive }; diff --git a/packages/select/select.test.mts b/packages/select/select.test.mts index 23aed402f..450783ad2 100644 --- a/packages/select/select.test.mts +++ b/packages/select/select.test.mts @@ -33,7 +33,7 @@ describe('select prompt', () => { 5 6 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('down'); @@ -47,7 +47,7 @@ describe('select prompt', () => { 5 6 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -72,7 +72,7 @@ describe('select prompt', () => { 5 6 7 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -92,7 +92,7 @@ describe('select prompt', () => { "? Select a number (Use arrow keys) ❯ 1 2 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -118,7 +118,7 @@ describe('select prompt', () => { 8 9 10 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -136,7 +136,7 @@ describe('select prompt', () => { "? Select a number (Use arrow keys) ❯ 1 2 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('up'); @@ -145,7 +145,7 @@ describe('select prompt', () => { "? Select a number ❯ 11 12 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -164,7 +164,7 @@ describe('select prompt', () => { "? Select a number (Use arrow keys) ❯ 1 2 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('up'); @@ -173,7 +173,7 @@ describe('select prompt', () => { "? Select a number (Use arrow keys) ❯ 1 2 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); @@ -192,7 +192,7 @@ describe('select prompt', () => { "? Select a number (Use arrow keys) ❯ 1 2 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); numberedChoices.forEach(() => events.keypress('down')); @@ -201,7 +201,7 @@ describe('select prompt', () => { "? Select a number 11 ❯ 12 - (Move up and down to reveal more choices)" + (Use arrow keys to reveal more choices)" `); events.keypress('enter'); From 27feef7ad685470e7b3e9e4e6c77556505d360e2 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 29 Aug 2023 21:18:56 +0100 Subject: [PATCH 14/94] Simplify --- packages/core/src/lib/pagination.mts | 13 ++++---- packages/core/src/lib/position.mts | 44 +++++++++++++--------------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/packages/core/src/lib/pagination.mts b/packages/core/src/lib/pagination.mts index d893dab1c..ca412d742 100644 --- a/packages/core/src/lib/pagination.mts +++ b/packages/core/src/lib/pagination.mts @@ -60,11 +60,14 @@ export function usePagination({ const width = cliWidth({ defaultWidth: 80, output: rl.output }); const output = items.map((item, index) => render({ item, index, active })).join('\n'); const lines = breakLines(output, width).split('\n'); - state.current.position = (loop ? infinite : finite)({ - active: { current: active, previous: state.current.lastActive }, - total: lines.length, - pageSize, - })(state.current.position); + state.current.position = (loop ? infinite : finite)( + { + active: { current: active, previous: state.current.lastActive }, + total: lines.length, + pageSize, + }, + state.current.position, + ); state.current.lastActive = active; // Rotate lines such that the active index is at the current position diff --git a/packages/core/src/lib/position.mts b/packages/core/src/lib/position.mts index 551bcbc95..71d9e8833 100644 --- a/packages/core/src/lib/position.mts +++ b/packages/core/src/lib/position.mts @@ -13,37 +13,33 @@ type PageInfo = { * Given information about a page, decides the next position at which the active * item should be rendered in the page. */ -type PositionReducer = (info: PageInfo) => (pointer: number) => number; +type PositionReducer = (info: PageInfo, pointer: number) => number; /** * Creates the next position for the active item considering a finite list of * items to be rendered on a page. */ -export const finite: PositionReducer = - ({ pageSize, total, active }) => - () => { - const middle = Math.floor(pageSize / 2); - return total <= pageSize || active.current < middle - ? active.current - : active.current >= total - middle - ? active.current + pageSize - total - : middle; - }; +export const finite: PositionReducer = ({ pageSize, total, active }) => { + const middle = Math.floor(pageSize / 2); + return total <= pageSize || active.current < middle + ? active.current + : active.current >= total - middle + ? active.current + pageSize - total + : middle; +}; /** * Creates the next position for the active item considering an infinitely * looping list of items to be rendered on the page. */ -export const infinite: PositionReducer = - ({ active, total, pageSize }) => - (pointer) => - total <= pageSize - ? active.current - : /** - * Move the position only when the user moves down, and when the - * navigation fits within a single page - */ - active.previous < active.current && active.current - active.previous < pageSize - ? // Limit it to the middle of the list - Math.min(Math.floor(pageSize / 2), pointer + active.current - active.previous) - : pointer; +export const infinite: PositionReducer = ({ active, total, pageSize }, pointer) => + total <= pageSize + ? active.current + : /** + * Move the position only when the user moves down, and when the + * navigation fits within a single page + */ + active.previous < active.current && active.current - active.previous < pageSize + ? // Limit it to the middle of the list + Math.min(Math.floor(pageSize / 2), pointer + active.current - active.previous) + : pointer; From 0bd3333f556db54ca582dd4aaf7204cbd8f5cb15 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 29 Aug 2023 21:38:20 +0100 Subject: [PATCH 15/94] Simplify --- packages/select/src/choice.mts | 14 ++++++++++++++ packages/select/src/choice.type.mts | 7 ------- packages/select/src/index.mts | 24 +++--------------------- packages/select/src/item.type.mts | 4 ---- packages/select/src/render.mts | 2 +- packages/select/src/selectable.mts | 6 ------ 6 files changed, 18 insertions(+), 39 deletions(-) create mode 100644 packages/select/src/choice.mts delete mode 100644 packages/select/src/choice.type.mts delete mode 100644 packages/select/src/item.type.mts delete mode 100644 packages/select/src/selectable.mts diff --git a/packages/select/src/choice.mts b/packages/select/src/choice.mts new file mode 100644 index 000000000..d5d0da6e4 --- /dev/null +++ b/packages/select/src/choice.mts @@ -0,0 +1,14 @@ +import { Separator } from '@inquirer/core'; + +export type Choice = { + value: Value; + name?: string; + description?: string; + disabled?: boolean | string; + type?: never; +}; + +export type Item = Separator | Choice; + +export const selectable = (item: Item): item is Choice => + !Separator.isSeparator(item) && !item.disabled; diff --git a/packages/select/src/choice.type.mts b/packages/select/src/choice.type.mts deleted file mode 100644 index 29545b3bf..000000000 --- a/packages/select/src/choice.type.mts +++ /dev/null @@ -1,7 +0,0 @@ -export type Choice = { - value: Value; - name?: string; - description?: string; - disabled?: boolean | string; - type?: never; -}; diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index ec5d6688a..4b340c970 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -6,27 +6,15 @@ import { usePagination, useRef, isEnterKey, - isUpKey, - isDownKey, isNumberKey, Separator, AsyncPromptConfig, } from '@inquirer/core'; import type {} from '@inquirer/type'; import chalk from 'chalk'; -import figures from 'figures'; import ansiEscapes from 'ansi-escapes'; import { render } from './render.mjs'; -import { selectable } from './selectable.mjs'; -import { Item } from './item.type.mjs'; - -type Choice = { - value: Value; - name?: string; - description?: string; - disabled?: boolean | string; - type?: never; -}; +import { Choice, Item, selectable } from './choice.mjs'; type SelectConfig = AsyncPromptConfig & { choices: ReadonlyArray | Separator>; @@ -34,19 +22,13 @@ type SelectConfig = AsyncPromptConfig & { loop?: boolean; }; -function isSelectableChoice( - choice: undefined | Separator | Choice, -): choice is Choice { - return choice != null && !Separator.isSeparator(choice) && !choice.disabled; -} - export default createPrompt( ( config: SelectConfig, done: (value: Value) => void, ): string => { const { choices } = config; - if (!choices.some(isSelectableChoice)) { + if (!choices.some(selectable)) { throw new Error('[select prompt] No selectable choices. All choices are disabled.'); } const firstRender = useRef(true); @@ -70,7 +52,7 @@ export default createPrompt( const position = Number(key.name) - 1; // Abort if the choice doesn't exists or if disabled - if (!isSelectableChoice(choices[position])) { + if (choices[position] == null || !selectable(choices[position])) { return; } diff --git a/packages/select/src/item.type.mts b/packages/select/src/item.type.mts deleted file mode 100644 index 2fc384b9d..000000000 --- a/packages/select/src/item.type.mts +++ /dev/null @@ -1,4 +0,0 @@ -import { Separator } from '@inquirer/core'; -import { Choice } from './choice.type.mjs'; - -export type Item = Separator | Choice; diff --git a/packages/select/src/render.mts b/packages/select/src/render.mts index d967fa926..a6490ef29 100644 --- a/packages/select/src/render.mts +++ b/packages/select/src/render.mts @@ -1,7 +1,7 @@ import { Paged, Separator } from '@inquirer/core'; import { dim, cyan } from 'chalk'; import { pointer } from 'figures'; -import { Item } from './item.type.mjs'; +import { Item } from './choice.mjs'; export const render = ({ item, index, active }: Paged>) => { if (Separator.isSeparator(item)) { diff --git a/packages/select/src/selectable.mts b/packages/select/src/selectable.mts deleted file mode 100644 index 420e5469a..000000000 --- a/packages/select/src/selectable.mts +++ /dev/null @@ -1,6 +0,0 @@ -import { Choice } from './choice.type.mjs'; -import { Separator } from './index.mjs'; -import { Item } from './item.type.mjs'; - -export const selectable = (item: Item): item is Choice => - !Separator.isSeparator(item) && !item.disabled; From f77395fc7998a2e230cd84a2caf837a433a63e6f Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 29 Aug 2023 21:46:35 +0100 Subject: [PATCH 16/94] Simplify --- packages/checkbox/src/choice.mts | 22 +++++++++++ packages/checkbox/src/choice.type.mts | 7 ---- packages/checkbox/src/index.mts | 53 +++++---------------------- packages/checkbox/src/item.type.mts | 4 -- packages/checkbox/src/selectable.mts | 6 --- 5 files changed, 32 insertions(+), 60 deletions(-) create mode 100644 packages/checkbox/src/choice.mts delete mode 100644 packages/checkbox/src/choice.type.mts delete mode 100644 packages/checkbox/src/item.type.mts delete mode 100644 packages/checkbox/src/selectable.mts diff --git a/packages/checkbox/src/choice.mts b/packages/checkbox/src/choice.mts new file mode 100644 index 000000000..7c7acd308 --- /dev/null +++ b/packages/checkbox/src/choice.mts @@ -0,0 +1,22 @@ +import { Separator } from '@inquirer/core'; + +export type Choice = { + name?: string; + value: Value; + disabled?: boolean | string; + checked?: boolean; + type?: never; +}; + +export type Item = Separator | Choice; + +export const selectable = (item: Item): item is Choice => + !Separator.isSeparator(item) && !item.disabled; + +export const check = + (checked: boolean) => + (item: Item): Item => + selectable(item) ? { ...item, checked } : item; + +export const toggle = (item: Item): Item => + check(!item.checked)(item); diff --git a/packages/checkbox/src/choice.type.mts b/packages/checkbox/src/choice.type.mts deleted file mode 100644 index 43855fa7f..000000000 --- a/packages/checkbox/src/choice.type.mts +++ /dev/null @@ -1,7 +0,0 @@ -export type Choice = { - name?: string; - value: Value; - disabled?: boolean | string; - checked?: boolean; - type?: never; -}; diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 4a97a9536..fd290daac 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -4,9 +4,6 @@ import { useKeypress, usePrefix, usePagination, - Paged, - isUpKey, - isDownKey, isSpaceKey, isNumberKey, isEnterKey, @@ -14,11 +11,9 @@ import { } from '@inquirer/core'; import type {} from '@inquirer/type'; import chalk from 'chalk'; -import figures from 'figures'; import ansiEscapes from 'ansi-escapes'; -import { Choice, render } from './render.mjs'; -import { selectable } from './selectable.mjs'; -import { Item } from './item.type.mjs'; +import { render } from './render.mjs'; +import { selectable, Item, Choice, toggle, check } from './choice.mjs'; type Config = { prefix?: string; @@ -29,12 +24,6 @@ type Config = { loop?: boolean; }; -function isSelectableChoice( - choice: undefined | Separator | Choice, -): choice is Choice { - return choice != null && !Separator.isSeparator(choice) && !choice.disabled; -} - export default createPrompt( ( config: Config, @@ -61,60 +50,38 @@ export default createPrompt( setStatus('done'); done( choices - .filter((choice) => isSelectableChoice(choice) && choice.checked) + .filter((choice) => selectable(choice) && choice.checked) .map((choice) => (choice as Choice).value), ); } else if (isSpaceKey(key)) { setShowHelpTip(false); - setChoices( - choices.map((choice, i) => { - if (i === active && isSelectableChoice(choice)) { - return { ...choice, checked: !choice.checked }; - } - - return choice; - }), - ); + setChoices(choices.map((choice, i) => (i === active ? toggle(choice) : choice))); } else if (key.name === 'a') { const selectAll = Boolean( - choices.find((choice) => isSelectableChoice(choice) && !choice.checked), - ); - setChoices( - choices.map((choice) => - isSelectableChoice(choice) ? { ...choice, checked: selectAll } : choice, - ), + choices.find((choice) => selectable(choice) && !choice.checked), ); + setChoices(choices.map(check(selectAll))); } else if (key.name === 'i') { - setChoices( - choices.map((choice) => - isSelectableChoice(choice) ? { ...choice, checked: !choice.checked } : choice, - ), - ); + setChoices(choices.map(toggle)); } else if (isNumberKey(key)) { // Adjust index to start at 1 const position = Number(key.name) - 1; // Abort if the choice doesn't exists or if disabled - if (!isSelectableChoice(choices[position])) { + if (choices[position] == null || !selectable(choices[position])) { return; } setActive(position); setChoices( - choices.map((choice, i) => { - if (i === position && isSelectableChoice(choice)) { - return { ...choice, checked: !choice.checked }; - } - - return choice; - }), + choices.map((choice, i) => (i === position ? toggle(choice) : choice)), ); } }); if (status === 'done') { const selection = choices - .filter((choice) => isSelectableChoice(choice) && choice.checked) + .filter((choice) => selectable(choice) && choice.checked) .map( (choice) => (choice as Choice).name || (choice as Choice).value, ); diff --git a/packages/checkbox/src/item.type.mts b/packages/checkbox/src/item.type.mts deleted file mode 100644 index 2fc384b9d..000000000 --- a/packages/checkbox/src/item.type.mts +++ /dev/null @@ -1,4 +0,0 @@ -import { Separator } from '@inquirer/core'; -import { Choice } from './choice.type.mjs'; - -export type Item = Separator | Choice; diff --git a/packages/checkbox/src/selectable.mts b/packages/checkbox/src/selectable.mts deleted file mode 100644 index 420e5469a..000000000 --- a/packages/checkbox/src/selectable.mts +++ /dev/null @@ -1,6 +0,0 @@ -import { Choice } from './choice.type.mjs'; -import { Separator } from './index.mjs'; -import { Item } from './item.type.mjs'; - -export const selectable = (item: Item): item is Choice => - !Separator.isSeparator(item) && !item.disabled; From 1e94acb32839bbcb76c299edc02aad3db16fa3f1 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 29 Aug 2023 21:56:57 +0100 Subject: [PATCH 17/94] Fix typescript issues --- packages/checkbox/src/choice.mts | 2 +- packages/checkbox/src/index.mts | 2 +- packages/checkbox/src/render.mts | 2 +- packages/select/src/index.mts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/checkbox/src/choice.mts b/packages/checkbox/src/choice.mts index 7c7acd308..22b3b599c 100644 --- a/packages/checkbox/src/choice.mts +++ b/packages/checkbox/src/choice.mts @@ -19,4 +19,4 @@ export const check = selectable(item) ? { ...item, checked } : item; export const toggle = (item: Item): Item => - check(!item.checked)(item); + selectable(item) ? { ...item, checked: !item.checked } : item; diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index fd290daac..275ea4d67 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -68,7 +68,7 @@ export default createPrompt( const position = Number(key.name) - 1; // Abort if the choice doesn't exists or if disabled - if (choices[position] == null || !selectable(choices[position])) { + if (choices[position] == null || !selectable(choices[position]!)) { return; } diff --git a/packages/checkbox/src/render.mts b/packages/checkbox/src/render.mts index e2dab27a4..376974904 100644 --- a/packages/checkbox/src/render.mts +++ b/packages/checkbox/src/render.mts @@ -2,7 +2,7 @@ import { Paged, Separator } from '@inquirer/core'; import type {} from '@inquirer/type'; import { dim, green, cyan } from 'chalk'; import { circle, circleFilled, pointer } from 'figures'; -import { Item } from './item.type.mjs'; +import type { Item } from './choice.mjs'; export const render = ({ item, index, active }: Paged>) => { if (Separator.isSeparator(item)) { diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 4b340c970..077ed27c4 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -52,7 +52,7 @@ export default createPrompt( const position = Number(key.name) - 1; // Abort if the choice doesn't exists or if disabled - if (choices[position] == null || !selectable(choices[position])) { + if (choices[position] == null || !selectable(choices[position]!)) { return; } From 9a5b431ba9f6afcaf8c457f614c4e0eb25ff736b Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 00:13:28 +0100 Subject: [PATCH 18/94] Avoid unnecessary check --- packages/core/src/lib/pagination.mts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/lib/pagination.mts b/packages/core/src/lib/pagination.mts index ca412d742..9214fc4cf 100644 --- a/packages/core/src/lib/pagination.mts +++ b/packages/core/src/lib/pagination.mts @@ -53,7 +53,6 @@ export function usePagination({ while (!selectable({ item: items[next]!, index: next, active })) { next = (next + items.length + offset) % items.length; } - if (next === active) return; setActive(next); } }); From 43b972f1bc2a15dff38900ac8f6315020c7b8322 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 00:18:43 +0100 Subject: [PATCH 19/94] Fix imports from CommonJS modules --- packages/checkbox/src/render.mts | 12 ++++++------ packages/select/src/render.mts | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/checkbox/src/render.mts b/packages/checkbox/src/render.mts index 376974904..15d9f2e3d 100644 --- a/packages/checkbox/src/render.mts +++ b/packages/checkbox/src/render.mts @@ -1,7 +1,7 @@ import { Paged, Separator } from '@inquirer/core'; import type {} from '@inquirer/type'; -import { dim, green, cyan } from 'chalk'; -import { circle, circleFilled, pointer } from 'figures'; +import chalk from 'chalk'; +import figures from 'figures'; import type { Item } from './choice.mjs'; export const render = ({ item, index, active }: Paged>) => { @@ -13,11 +13,11 @@ export const render = ({ item, index, active }: Paged>) => { if (item.disabled) { const disabledLabel = typeof item.disabled === 'string' ? item.disabled : '(disabled)'; - return dim(`- ${line} ${disabledLabel}`); + return chalk.dim(`- ${line} ${disabledLabel}`); } - const checkbox = item.checked ? green(circleFilled) : circle; - const color = index === active ? cyan : (x: string) => x; - const prefix = index === active ? pointer : ' '; + const checkbox = item.checked ? chalk.green(figures.circleFilled) : figures.circle; + const color = index === active ? chalk.cyan : (x: string) => x; + const prefix = index === active ? figures.pointer : ' '; return color(`${prefix}${checkbox} ${line}`); }; diff --git a/packages/select/src/render.mts b/packages/select/src/render.mts index a6490ef29..2083aca3a 100644 --- a/packages/select/src/render.mts +++ b/packages/select/src/render.mts @@ -1,6 +1,6 @@ import { Paged, Separator } from '@inquirer/core'; -import { dim, cyan } from 'chalk'; -import { pointer } from 'figures'; +import chalk from 'chalk'; +import figures from 'figures'; import { Item } from './choice.mjs'; export const render = ({ item, index, active }: Paged>) => { @@ -12,10 +12,10 @@ export const render = ({ item, index, active }: Paged>) => { if (item.disabled) { const disabledLabel = typeof item.disabled === 'string' ? item.disabled : '(disabled)'; - return dim(`- ${line} ${disabledLabel}`); + return chalk.dim(`- ${line} ${disabledLabel}`); } - const color = index === active ? cyan : (x: string) => x; - const prefix = index === active ? pointer : ` `; + const color = index === active ? chalk.cyan : (x: string) => x; + const prefix = index === active ? figures.pointer : ` `; return color(`${prefix} ${line}`); }; From 8aa11dffbc71b76a6eacdc2a4f6be4064fa96b06 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 00:24:08 +0100 Subject: [PATCH 20/94] Move pagination into module --- packages/core/src/index.mts | 2 +- packages/core/src/lib/pagination/index.mts | 2 + .../src/lib/{ => pagination}/position.mts | 0 packages/core/src/lib/pagination/types.mts | 23 +++++++++++ .../use-pagination.mts} | 38 ++++++------------- 5 files changed, 37 insertions(+), 28 deletions(-) create mode 100644 packages/core/src/lib/pagination/index.mts rename packages/core/src/lib/{ => pagination}/position.mts (100%) create mode 100644 packages/core/src/lib/pagination/types.mts rename packages/core/src/lib/{pagination.mts => pagination/use-pagination.mts} (73%) diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index b4ad948d2..a4a4bf5e9 100644 --- a/packages/core/src/index.mts +++ b/packages/core/src/index.mts @@ -8,7 +8,7 @@ import { getPromptConfig } from './lib/options.mjs'; export { usePrefix } from './lib/prefix.mjs'; export * from './lib/key.mjs'; export * from './lib/Separator.mjs'; -export * from './lib/pagination.mjs'; +export * from './lib/pagination/index.mjs'; export type InquirerReadline = readline.ReadLine & { output: MuteStream; diff --git a/packages/core/src/lib/pagination/index.mts b/packages/core/src/lib/pagination/index.mts new file mode 100644 index 000000000..2f026bed2 --- /dev/null +++ b/packages/core/src/lib/pagination/index.mts @@ -0,0 +1,2 @@ +export * from './types.mjs'; +export * from './use-pagination.mjs'; diff --git a/packages/core/src/lib/position.mts b/packages/core/src/lib/pagination/position.mts similarity index 100% rename from packages/core/src/lib/position.mts rename to packages/core/src/lib/pagination/position.mts diff --git a/packages/core/src/lib/pagination/types.mts b/packages/core/src/lib/pagination/types.mts new file mode 100644 index 000000000..e373fbddf --- /dev/null +++ b/packages/core/src/lib/pagination/types.mts @@ -0,0 +1,23 @@ +type F = (...args: A) => B; +type UnaryF = F<[T], R>; +type Action = UnaryF; + +export type Paged = { + item: T; + active: number; + index: number; +}; + +export type Pagination = { + items: readonly T[]; + selectable: UnaryF, boolean>; + render: UnaryF, string>; + pageSize?: number; + loop?: boolean; +}; + +export type Page = { + contents: string; + active: number; + setActive: Action; +}; diff --git a/packages/core/src/lib/pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts similarity index 73% rename from packages/core/src/lib/pagination.mts rename to packages/core/src/lib/pagination/use-pagination.mts index 9214fc4cf..9f51ca0d4 100644 --- a/packages/core/src/lib/pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -1,32 +1,16 @@ import chalk from 'chalk'; -import { context, useState, useRef, useKeypress, isUpKey, isDownKey } from '../index.mjs'; +import { + context, + useState, + useRef, + useKeypress, + isUpKey, + isDownKey, +} from '../../index.mjs'; import cliWidth from 'cli-width'; -import { breakLines, rotate } from './utils.mjs'; +import { breakLines, rotate } from '../utils.mjs'; import { finite, infinite } from './position.mjs'; - -type F = (...args: A) => B; -type UnaryF = F<[T], R>; -type Action = UnaryF; - -export type Paged = { - item: T; - active: number; - index: number; -}; - -type Options = { - items: readonly T[]; - selectable: UnaryF, boolean>; - render: UnaryF, string>; - pageSize?: number; - loop?: boolean; -}; - -type Page = { - contents: string; - active: number; - setActive: Action; -}; +import { Pagination, Page } from './types.mjs'; export function usePagination({ items, @@ -34,7 +18,7 @@ export function usePagination({ render, pageSize = 7, loop = true, -}: Options): Page { +}: Pagination): Page { const { rl } = context.getStore(); const state = useRef({ position: 0, From d764d90f8c09d8fb21245e0152ee89a94615daa4 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 01:19:46 +0100 Subject: [PATCH 21/94] Simplify selectable --- packages/checkbox/src/index.mts | 2 +- packages/core/src/lib/pagination/types.mts | 2 +- packages/core/src/lib/pagination/use-pagination.mts | 2 +- packages/select/src/index.mts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 275ea4d67..32f8144ff 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -40,7 +40,7 @@ export default createPrompt( const { contents, active, setActive } = usePagination>({ items: choices, render, - selectable: ({ item }) => selectable(item), + selectable, pageSize: config.pageSize, loop: config.loop, }); diff --git a/packages/core/src/lib/pagination/types.mts b/packages/core/src/lib/pagination/types.mts index e373fbddf..abffeb526 100644 --- a/packages/core/src/lib/pagination/types.mts +++ b/packages/core/src/lib/pagination/types.mts @@ -10,7 +10,7 @@ export type Paged = { export type Pagination = { items: readonly T[]; - selectable: UnaryF, boolean>; + selectable: UnaryF; render: UnaryF, string>; pageSize?: number; loop?: boolean; diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index 9f51ca0d4..61049c728 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -34,7 +34,7 @@ export function usePagination({ if (isUpKey(key) || isDownKey(key)) { const offset = isUpKey(key) ? -1 : 1; let next = (active + items.length + offset) % items.length; - while (!selectable({ item: items[next]!, index: next, active })) { + while (!selectable(items[next]!)) { next = (next + items.length + offset) % items.length; } setActive(next); diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 077ed27c4..da06e1b26 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -38,7 +38,7 @@ export default createPrompt( const { contents, active, setActive } = usePagination>({ items: choices, render, - selectable: ({ item }) => selectable(item), + selectable, pageSize: config.pageSize, loop: config.loop, }); From d551c2f5a639d8f2d9b2b0d0902b10e1ec4085c5 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 01:20:02 +0100 Subject: [PATCH 22/94] Allow working on readonly items --- packages/core/src/lib/utils.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/lib/utils.mts b/packages/core/src/lib/utils.mts index c5c50d5c1..51f57d955 100644 --- a/packages/core/src/lib/utils.mts +++ b/packages/core/src/lib/utils.mts @@ -27,7 +27,7 @@ const index = (max: number) => (value: number) => ((value % max) + max) % max; */ export const rotate = (count: number) => - (items: T[]): T[] => { + (items: readonly T[]): readonly T[] => { const offset = index(items.length)(count); return items.slice(offset).concat(items.slice(0, offset)); }; From 900a2cbeb38f102bd716bb24295a967ea6a988c0 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 01:21:21 +0100 Subject: [PATCH 23/94] Introduce a modular primitive So we don't need to join and split again in certain contexts. Also supports fixing a width. --- packages/core/src/lib/utils.mts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/core/src/lib/utils.mts b/packages/core/src/lib/utils.mts index 51f57d955..8dae1ad13 100644 --- a/packages/core/src/lib/utils.mts +++ b/packages/core/src/lib/utils.mts @@ -1,5 +1,19 @@ import wrapAnsi from 'wrap-ansi'; +/** + * Split a string into lines at specific width, in addition to line breaks. This + * function is ANSI code friendly and it'll ignore invisible codes during width + * calculation. + */ +export const splitLines = + (width: number) => + (content: string): string[] => + content.split('\n').flatMap((line) => + wrapAnsi(line, width, { trim: false, hard: true }) + .split('\n') + .map((line) => line.trimEnd()), + ); + /** * Force line returns at specific width. This function is ANSI code friendly and it'll * ignore invisible codes during width calculation. @@ -8,14 +22,7 @@ import wrapAnsi from 'wrap-ansi'; * @return {string} */ export const breakLines = (content: string, width: number): string => - content - .split('\n') - .flatMap((line) => - wrapAnsi(line, width, { trim: false, hard: true }) - .split('\n') - .map((line) => line.trimEnd()), - ) - .join('\n'); + splitLines(width)(content).join('\n'); /** * Creates a 0-based index out of an integer, wrapping around if necessary. From ab8fa207f2d52a09a0e025ff58b2b13c79d85023 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 01:22:03 +0100 Subject: [PATCH 24/94] Render only items that may belong to the page --- .../src/lib/pagination/use-pagination.mts | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index 61049c728..08242ca4f 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -8,7 +8,7 @@ import { isDownKey, } from '../../index.mjs'; import cliWidth from 'cli-width'; -import { breakLines, rotate } from '../utils.mjs'; +import { rotate, splitLines } from '../utils.mjs'; import { finite, infinite } from './position.mjs'; import { Pagination, Page } from './types.mjs'; @@ -25,6 +25,7 @@ export function usePagination({ lastActive: 0, }); const [active, setActive] = useState(0); + const indexed = items.map((item, index) => ({ item, index })); useKeypress((key) => { if ( !loop && @@ -41,23 +42,34 @@ export function usePagination({ } }); const width = cliWidth({ defaultWidth: 80, output: rl.output }); - const output = items.map((item, index) => render({ item, index, active })).join('\n'); - const lines = breakLines(output, width).split('\n'); + const split = splitLines(width); state.current.position = (loop ? infinite : finite)( { active: { current: active, previous: state.current.lastActive }, - total: lines.length, + total: items.length, pageSize, }, state.current.position, ); state.current.lastActive = active; - // Rotate lines such that the active index is at the current position - const contents = rotate(active - state.current.position)(lines) + const slice = rotate(active - state.current.position)(indexed).slice(0, pageSize); + const previous = slice + .filter((_, i) => i < state.current.position) + .map((x) => render({ ...x, active })) + .flatMap(split); + const current = split(render({ ...slice[state.current.position]!, active })); + const rest = slice + .filter((_, i) => i > state.current.position) + .map((x) => render({ ...x, active })) + .flatMap(split); + + const lines = previous.concat(current).concat(rest); + + const contents = rotate(previous.length - state.current.position)(lines) .slice(0, pageSize) .concat( - lines.length <= pageSize + items.length <= pageSize ? [] : [chalk.dim('(Use arrow keys to reveal more choices)')], ) From 5ee05df1d6dbb12f2be04b9db9e8f9ca7d933f98 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 01:36:04 +0100 Subject: [PATCH 25/94] Simplify rendering --- packages/checkbox/src/render.mts | 6 +-- packages/core/src/lib/pagination/types.mts | 2 +- .../src/lib/pagination/use-pagination.mts | 35 +++-------------- .../core/src/lib/pagination/use-window.mts | 39 +++++++++++++++++++ packages/select/src/render.mts | 6 +-- 5 files changed, 51 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/lib/pagination/use-window.mts diff --git a/packages/checkbox/src/render.mts b/packages/checkbox/src/render.mts index 15d9f2e3d..6a58569b2 100644 --- a/packages/checkbox/src/render.mts +++ b/packages/checkbox/src/render.mts @@ -4,7 +4,7 @@ import chalk from 'chalk'; import figures from 'figures'; import type { Item } from './choice.mjs'; -export const render = ({ item, index, active }: Paged>) => { +export const render = ({ item, active }: Paged>) => { if (Separator.isSeparator(item)) { return ` ${item.separator}`; } @@ -17,7 +17,7 @@ export const render = ({ item, index, active }: Paged>) => { } const checkbox = item.checked ? chalk.green(figures.circleFilled) : figures.circle; - const color = index === active ? chalk.cyan : (x: string) => x; - const prefix = index === active ? figures.pointer : ' '; + const color = active ? chalk.cyan : (x: string) => x; + const prefix = active ? figures.pointer : ' '; return color(`${prefix}${checkbox} ${line}`); }; diff --git a/packages/core/src/lib/pagination/types.mts b/packages/core/src/lib/pagination/types.mts index abffeb526..3f596a0c4 100644 --- a/packages/core/src/lib/pagination/types.mts +++ b/packages/core/src/lib/pagination/types.mts @@ -4,7 +4,7 @@ type Action = UnaryF; export type Paged = { item: T; - active: number; + active?: boolean; index: number; }; diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index 08242ca4f..ae30f8589 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -1,16 +1,8 @@ import chalk from 'chalk'; -import { - context, - useState, - useRef, - useKeypress, - isUpKey, - isDownKey, -} from '../../index.mjs'; -import cliWidth from 'cli-width'; -import { rotate, splitLines } from '../utils.mjs'; +import { useState, useRef, useKeypress, isUpKey, isDownKey } from '../../index.mjs'; import { finite, infinite } from './position.mjs'; import { Pagination, Page } from './types.mjs'; +import { useWindow } from './use-window.mjs'; export function usePagination({ items, @@ -19,13 +11,11 @@ export function usePagination({ pageSize = 7, loop = true, }: Pagination): Page { - const { rl } = context.getStore(); const state = useRef({ position: 0, lastActive: 0, }); const [active, setActive] = useState(0); - const indexed = items.map((item, index) => ({ item, index })); useKeypress((key) => { if ( !loop && @@ -41,9 +31,7 @@ export function usePagination({ setActive(next); } }); - const width = cliWidth({ defaultWidth: 80, output: rl.output }); - const split = splitLines(width); - state.current.position = (loop ? infinite : finite)( + const position = (loop ? infinite : finite)( { active: { current: active, previous: state.current.lastActive }, total: items.length, @@ -51,23 +39,10 @@ export function usePagination({ }, state.current.position, ); + state.current.position = position; state.current.lastActive = active; - const slice = rotate(active - state.current.position)(indexed).slice(0, pageSize); - const previous = slice - .filter((_, i) => i < state.current.position) - .map((x) => render({ ...x, active })) - .flatMap(split); - const current = split(render({ ...slice[state.current.position]!, active })); - const rest = slice - .filter((_, i) => i > state.current.position) - .map((x) => render({ ...x, active })) - .flatMap(split); - - const lines = previous.concat(current).concat(rest); - - const contents = rotate(previous.length - state.current.position)(lines) - .slice(0, pageSize) + const contents = useWindow({ items, render, active, position, pageSize }) .concat( items.length <= pageSize ? [] diff --git a/packages/core/src/lib/pagination/use-window.mts b/packages/core/src/lib/pagination/use-window.mts new file mode 100644 index 000000000..e4a387b8e --- /dev/null +++ b/packages/core/src/lib/pagination/use-window.mts @@ -0,0 +1,39 @@ +import { context } from '../../index.mjs'; +import cliWidth from 'cli-width'; +import { rotate, splitLines } from '../utils.mjs'; +import { Paged } from './types.mjs'; + +type Windowed = { + items: readonly T[]; + render: (paged: Paged) => string; + active: number; + position: number; + pageSize: number; +}; + +export function useWindow({ + items, + render, + active, + position, + pageSize, +}: Windowed): string[] { + const { rl } = context.getStore(); + const width = cliWidth({ defaultWidth: 80, output: rl.output }); + const split = splitLines(width); + + const indexed = items.map((item, index) => ({ item, index })); + const slice = rotate(active - position)(indexed).slice(0, pageSize); + const previous = slice + .filter((_, i) => i < position) + .map(render) + .flatMap(split); + const current = split(render({ ...slice[position]!, active: true })); + const rest = slice + .filter((_, i) => i > position) + .map(render) + .flatMap(split); + + const lines = previous.concat(current).concat(rest); + return rotate(previous.length - position)(lines).slice(0, pageSize); +} diff --git a/packages/select/src/render.mts b/packages/select/src/render.mts index 2083aca3a..368269abb 100644 --- a/packages/select/src/render.mts +++ b/packages/select/src/render.mts @@ -3,7 +3,7 @@ import chalk from 'chalk'; import figures from 'figures'; import { Item } from './choice.mjs'; -export const render = ({ item, index, active }: Paged>) => { +export const render = ({ item, active }: Paged>) => { if (Separator.isSeparator(item)) { return ` ${item.separator}`; } @@ -15,7 +15,7 @@ export const render = ({ item, index, active }: Paged>) => { return chalk.dim(`- ${line} ${disabledLabel}`); } - const color = index === active ? chalk.cyan : (x: string) => x; - const prefix = index === active ? figures.pointer : ` `; + const color = active ? chalk.cyan : (x: string) => x; + const prefix = active ? figures.pointer : ` `; return color(`${prefix} ${line}`); }; From 585c01ecb251f153429cc28da3e466db261d5a64 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 01:45:38 +0100 Subject: [PATCH 26/94] Prefer function to hook --- .../pagination/{use-window.mts => lines.mts} | 14 +++++------- .../src/lib/pagination/use-pagination.mts | 22 ++++++++++++++----- 2 files changed, 22 insertions(+), 14 deletions(-) rename packages/core/src/lib/pagination/{use-window.mts => lines.mts} (75%) diff --git a/packages/core/src/lib/pagination/use-window.mts b/packages/core/src/lib/pagination/lines.mts similarity index 75% rename from packages/core/src/lib/pagination/use-window.mts rename to packages/core/src/lib/pagination/lines.mts index e4a387b8e..773563546 100644 --- a/packages/core/src/lib/pagination/use-window.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -1,25 +1,23 @@ -import { context } from '../../index.mjs'; -import cliWidth from 'cli-width'; import { rotate, splitLines } from '../utils.mjs'; import { Paged } from './types.mjs'; -type Windowed = { +type Inputs = { items: readonly T[]; + width: number; render: (paged: Paged) => string; active: number; position: number; pageSize: number; }; -export function useWindow({ +export const lines = ({ items, + width, render, active, position, pageSize, -}: Windowed): string[] { - const { rl } = context.getStore(); - const width = cliWidth({ defaultWidth: 80, output: rl.output }); +}: Inputs): string[] => { const split = splitLines(width); const indexed = items.map((item, index) => ({ item, index })); @@ -36,4 +34,4 @@ export function useWindow({ const lines = previous.concat(current).concat(rest); return rotate(previous.length - position)(lines).slice(0, pageSize); -} +}; diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index ae30f8589..e0f1a6bcf 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -1,16 +1,26 @@ import chalk from 'chalk'; -import { useState, useRef, useKeypress, isUpKey, isDownKey } from '../../index.mjs'; +import { + useState, + useRef, + useKeypress, + isUpKey, + isDownKey, + context, +} from '../../index.mjs'; import { finite, infinite } from './position.mjs'; import { Pagination, Page } from './types.mjs'; -import { useWindow } from './use-window.mjs'; +import { lines } from './lines.mjs'; +import cliWidth from 'cli-width'; -export function usePagination({ +export const usePagination = ({ items, selectable, render, pageSize = 7, loop = true, -}: Pagination): Page { +}: Pagination): Page => { + const { rl } = context.getStore(); + const width = cliWidth({ defaultWidth: 80, output: rl.output }); const state = useRef({ position: 0, lastActive: 0, @@ -42,7 +52,7 @@ export function usePagination({ state.current.position = position; state.current.lastActive = active; - const contents = useWindow({ items, render, active, position, pageSize }) + const contents = lines({ items, width, render, active, position, pageSize }) .concat( items.length <= pageSize ? [] @@ -50,4 +60,4 @@ export function usePagination({ ) .join('\n'); return { contents, active, setActive }; -} +}; From 8bb3b3084d7e93a82a1525a21986b35ef0ce4ff9 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 17:00:24 +0100 Subject: [PATCH 27/94] Integrate speed dial into navigation --- packages/checkbox/src/index.mts | 10 +---- packages/core/src/lib/pagination/types.mts | 29 +++++++++++---- .../src/lib/pagination/use-navigation.mts | 37 +++++++++++++++++++ .../src/lib/pagination/use-pagination.mts | 18 ++------- packages/select/src/index.mts | 12 +----- 5 files changed, 65 insertions(+), 41 deletions(-) create mode 100644 packages/core/src/lib/pagination/use-navigation.mts diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 32f8144ff..d02fab4fd 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -37,7 +37,7 @@ export default createPrompt( ); const [showHelpTip, setShowHelpTip] = useState(true); const message = chalk.bold(config.message); - const { contents, active, setActive } = usePagination>({ + const { contents, active } = usePagination>({ items: choices, render, selectable, @@ -66,13 +66,7 @@ export default createPrompt( } else if (isNumberKey(key)) { // Adjust index to start at 1 const position = Number(key.name) - 1; - - // Abort if the choice doesn't exists or if disabled - if (choices[position] == null || !selectable(choices[position]!)) { - return; - } - - setActive(position); + // Toggle when speed dialled setChoices( choices.map((choice, i) => (i === position ? toggle(choice) : choice)), ); diff --git a/packages/core/src/lib/pagination/types.mts b/packages/core/src/lib/pagination/types.mts index 3f596a0c4..ebd11fd1e 100644 --- a/packages/core/src/lib/pagination/types.mts +++ b/packages/core/src/lib/pagination/types.mts @@ -8,16 +8,31 @@ export type Paged = { index: number; }; -export type Pagination = { +export type PageOptions = { + pageSize: number; + /** Allows wrapping on either sides of the list on navigation. */ + loop: boolean; + /** Allows quickly navigating to items 1-9 by pressing the number keys. */ + speedDial: boolean; +}; + +export type Activatable = { + active: number; + setActive: Action; +}; + +export type Selectable = { items: readonly T[]; selectable: UnaryF; - render: UnaryF, string>; - pageSize?: number; - loop?: boolean; }; -export type Page = { +export type Navigable = PageOptions & Selectable & Activatable; + +export type Pagination = Partial & + Selectable & { + render: UnaryF, string>; + }; + +export type Page = Activatable & { contents: string; - active: number; - setActive: Action; }; diff --git a/packages/core/src/lib/pagination/use-navigation.mts b/packages/core/src/lib/pagination/use-navigation.mts new file mode 100644 index 000000000..7a7a5ba96 --- /dev/null +++ b/packages/core/src/lib/pagination/use-navigation.mts @@ -0,0 +1,37 @@ +import { useKeypress, isUpKey, isDownKey, isNumberKey } from '../../index.mjs'; +import { Navigable } from './types.mjs'; + +export const useNavigation = ({ + items, + selectable, + active, + setActive, + speedDial, + loop, +}: Navigable) => { + useKeypress((key) => { + if ( + !loop && + ((active === 0 && isUpKey(key)) || (active === items.length - 1 && isDownKey(key))) + ) + return; + if (isUpKey(key) || isDownKey(key)) { + const offset = isUpKey(key) ? -1 : 1; + let next = (active + items.length + offset) % items.length; + while (!selectable(items[next]!)) { + next = (next + items.length + offset) % items.length; + } + setActive(next); + return; + } + if (speedDial && isNumberKey(key)) { + // Adjust index to start at 1 + const position = Number(key.name) - 1; + // Abort if the choice doesn't exists or if disabled + if (items[position] == null || !selectable(items[position]!)) { + return; + } + setActive(position); + } + }); +}; diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index e0f1a6bcf..385deb696 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -11,6 +11,7 @@ import { finite, infinite } from './position.mjs'; import { Pagination, Page } from './types.mjs'; import { lines } from './lines.mjs'; import cliWidth from 'cli-width'; +import { useNavigation } from './use-navigation.mjs'; export const usePagination = ({ items, @@ -18,6 +19,7 @@ export const usePagination = ({ render, pageSize = 7, loop = true, + speedDial = true, }: Pagination): Page => { const { rl } = context.getStore(); const width = cliWidth({ defaultWidth: 80, output: rl.output }); @@ -26,21 +28,7 @@ export const usePagination = ({ lastActive: 0, }); const [active, setActive] = useState(0); - useKeypress((key) => { - if ( - !loop && - ((active === 0 && isUpKey(key)) || (active === items.length - 1 && isDownKey(key))) - ) - return; - if (isUpKey(key) || isDownKey(key)) { - const offset = isUpKey(key) ? -1 : 1; - let next = (active + items.length + offset) % items.length; - while (!selectable(items[next]!)) { - next = (next + items.length + offset) % items.length; - } - setActive(next); - } - }); + useNavigation({ items, selectable, pageSize, active, setActive, loop, speedDial }); const position = (loop ? infinite : finite)( { active: { current: active, previous: state.current.lastActive }, diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index da06e1b26..0633da557 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -35,7 +35,7 @@ export default createPrompt( const prefix = usePrefix(); const [status, setStatus] = useState('pending'); - const { contents, active, setActive } = usePagination>({ + const { contents, active } = usePagination>({ items: choices, render, selectable, @@ -47,16 +47,6 @@ export default createPrompt( if (isEnterKey(key)) { setStatus('done'); done(choice.value); - } else if (isNumberKey(key)) { - // Adjust index to start at 1 - const position = Number(key.name) - 1; - - // Abort if the choice doesn't exists or if disabled - if (choices[position] == null || !selectable(choices[position]!)) { - return; - } - - setActive(position); } }); From 4e28822bfc90a5b64c3ac589b419e2215d0eeccc Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 17:06:32 +0100 Subject: [PATCH 28/94] Remove unnecessary imports --- packages/core/src/lib/pagination/use-pagination.mts | 9 +-------- packages/select/src/index.mts | 1 - 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index 385deb696..a444037e6 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -1,12 +1,5 @@ import chalk from 'chalk'; -import { - useState, - useRef, - useKeypress, - isUpKey, - isDownKey, - context, -} from '../../index.mjs'; +import { useState, useRef, context } from '../../index.mjs'; import { finite, infinite } from './position.mjs'; import { Pagination, Page } from './types.mjs'; import { lines } from './lines.mjs'; diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 0633da557..436342f1b 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -6,7 +6,6 @@ import { usePagination, useRef, isEnterKey, - isNumberKey, Separator, AsyncPromptConfig, } from '@inquirer/core'; From d2d5cb6d595e1c10bd9bb5e6b3e2d159fb6622a7 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 17:15:37 +0100 Subject: [PATCH 29/94] Export index --- packages/core/src/lib/utils.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/lib/utils.mts b/packages/core/src/lib/utils.mts index 8dae1ad13..7052fc734 100644 --- a/packages/core/src/lib/utils.mts +++ b/packages/core/src/lib/utils.mts @@ -27,7 +27,7 @@ export const breakLines = (content: string, width: number): string => /** * Creates a 0-based index out of an integer, wrapping around if necessary. */ -const index = (max: number) => (value: number) => ((value % max) + max) % max; +export const index = (max: number) => (value: number) => ((value % max) + max) % max; /** * Rotates an array of items by an integer number of positions. From d99948412931f5eb3429833924f42e283a6e7ec4 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 17:15:53 +0100 Subject: [PATCH 30/94] Simplify --- packages/core/src/lib/pagination/use-navigation.mts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/lib/pagination/use-navigation.mts b/packages/core/src/lib/pagination/use-navigation.mts index 7a7a5ba96..b7ac667ca 100644 --- a/packages/core/src/lib/pagination/use-navigation.mts +++ b/packages/core/src/lib/pagination/use-navigation.mts @@ -1,4 +1,5 @@ import { useKeypress, isUpKey, isDownKey, isNumberKey } from '../../index.mjs'; +import { index } from '../utils.mjs'; import { Navigable } from './types.mjs'; export const useNavigation = ({ @@ -17,10 +18,10 @@ export const useNavigation = ({ return; if (isUpKey(key) || isDownKey(key)) { const offset = isUpKey(key) ? -1 : 1; - let next = (active + items.length + offset) % items.length; - while (!selectable(items[next]!)) { - next = (next + items.length + offset) % items.length; - } + let next = active; + do { + next = index(items.length)(next + offset); + } while (!selectable(items[next]!)); setActive(next); return; } From 6f6085c75a9383f5b9abb2cd1005fbdaaf3dffda Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 17:18:11 +0100 Subject: [PATCH 31/94] Simplify --- packages/core/src/lib/pagination/use-navigation.mts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/core/src/lib/pagination/use-navigation.mts b/packages/core/src/lib/pagination/use-navigation.mts index b7ac667ca..3871c1f88 100644 --- a/packages/core/src/lib/pagination/use-navigation.mts +++ b/packages/core/src/lib/pagination/use-navigation.mts @@ -26,13 +26,10 @@ export const useNavigation = ({ return; } if (speedDial && isNumberKey(key)) { - // Adjust index to start at 1 - const position = Number(key.name) - 1; - // Abort if the choice doesn't exists or if disabled - if (items[position] == null || !selectable(items[position]!)) { - return; + const index = Number(key.name) - 1; + if (items[index] != null && selectable(items[index]!)) { + setActive(index); } - setActive(position); } }); }; From 603049adeec3957c963db6d9d05fc9ccf99a3bf3 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 18:19:43 +0100 Subject: [PATCH 32/94] Extract navigation hooks and use composition --- packages/checkbox/src/index.mts | 35 +++++++++-------- packages/core/src/index.mts | 1 + packages/core/src/lib/navigation/index.mts | 2 + packages/core/src/lib/navigation/types.mts | 6 +++ .../core/src/lib/navigation/use-scroll.mts | 31 +++++++++++++++ .../src/lib/navigation/use-speed-dial.mts | 20 ++++++++++ packages/core/src/lib/pagination/index.mts | 2 +- packages/core/src/lib/pagination/types.mts | 39 ++++++------------- .../src/lib/pagination/use-navigation.mts | 35 ----------------- .../src/lib/pagination/use-pagination.mts | 8 +--- packages/core/src/lib/types.mts | 12 ++++++ packages/select/src/index.mts | 21 ++++++---- 12 files changed, 117 insertions(+), 95 deletions(-) create mode 100644 packages/core/src/lib/navigation/index.mts create mode 100644 packages/core/src/lib/navigation/types.mts create mode 100644 packages/core/src/lib/navigation/use-scroll.mts create mode 100644 packages/core/src/lib/navigation/use-speed-dial.mts delete mode 100644 packages/core/src/lib/pagination/use-navigation.mts create mode 100644 packages/core/src/lib/types.mts diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index d02fab4fd..dde61a916 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -4,6 +4,8 @@ import { useKeypress, usePrefix, usePagination, + useScroll, + useSpeedDial, isSpaceKey, isNumberKey, isEnterKey, @@ -29,52 +31,51 @@ export default createPrompt( config: Config, done: (value: Array) => void, ): string => { - const { prefix = usePrefix(), instructions } = config; + const { prefix = usePrefix(), instructions, pageSize, loop, choices } = config; const [status, setStatus] = useState('pending'); - const [choices, setChoices] = useState>>(() => - config.choices.map((choice) => ({ ...choice })), + const [items, setItems] = useState>>( + choices.map((choice) => ({ ...choice })), ); const [showHelpTip, setShowHelpTip] = useState(true); const message = chalk.bold(config.message); - const { contents, active } = usePagination>({ - items: choices, + const { contents, active, setActive } = usePagination>({ + items, render, - selectable, - pageSize: config.pageSize, - loop: config.loop, + pageSize, + loop, }); + useSpeedDial({ items, selectable, setActive }); + useScroll({ items, selectable, active, setActive, loop }); useKeypress((key) => { if (isEnterKey(key)) { setStatus('done'); done( - choices + items .filter((choice) => selectable(choice) && choice.checked) .map((choice) => (choice as Choice).value), ); } else if (isSpaceKey(key)) { setShowHelpTip(false); - setChoices(choices.map((choice, i) => (i === active ? toggle(choice) : choice))); + setItems(items.map((choice, i) => (i === active ? toggle(choice) : choice))); } else if (key.name === 'a') { const selectAll = Boolean( - choices.find((choice) => selectable(choice) && !choice.checked), + items.find((choice) => selectable(choice) && !choice.checked), ); - setChoices(choices.map(check(selectAll))); + setItems(items.map(check(selectAll))); } else if (key.name === 'i') { - setChoices(choices.map(toggle)); + setItems(items.map(toggle)); } else if (isNumberKey(key)) { // Adjust index to start at 1 const position = Number(key.name) - 1; // Toggle when speed dialled - setChoices( - choices.map((choice, i) => (i === position ? toggle(choice) : choice)), - ); + setItems(items.map((choice, i) => (i === position ? toggle(choice) : choice))); } }); if (status === 'done') { - const selection = choices + const selection = items .filter((choice) => selectable(choice) && choice.checked) .map( (choice) => (choice as Choice).name || (choice as Choice).value, diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index a4a4bf5e9..a526c865e 100644 --- a/packages/core/src/index.mts +++ b/packages/core/src/index.mts @@ -8,6 +8,7 @@ import { getPromptConfig } from './lib/options.mjs'; export { usePrefix } from './lib/prefix.mjs'; export * from './lib/key.mjs'; export * from './lib/Separator.mjs'; +export * from './lib/navigation/index.mjs'; export * from './lib/pagination/index.mjs'; export type InquirerReadline = readline.ReadLine & { diff --git a/packages/core/src/lib/navigation/index.mts b/packages/core/src/lib/navigation/index.mts new file mode 100644 index 000000000..307387166 --- /dev/null +++ b/packages/core/src/lib/navigation/index.mts @@ -0,0 +1,2 @@ +export * from './use-speed-dial.mjs'; +export * from './use-scroll.mjs'; diff --git a/packages/core/src/lib/navigation/types.mts b/packages/core/src/lib/navigation/types.mts new file mode 100644 index 000000000..301d554b0 --- /dev/null +++ b/packages/core/src/lib/navigation/types.mts @@ -0,0 +1,6 @@ +import { HasSeveralOrdered, UnaryF } from '../types.mjs'; + +export type Selectable = HasSeveralOrdered & { + /** Returns whether an item can be selected. */ + selectable: UnaryF; +}; diff --git a/packages/core/src/lib/navigation/use-scroll.mts b/packages/core/src/lib/navigation/use-scroll.mts new file mode 100644 index 000000000..aa6f28978 --- /dev/null +++ b/packages/core/src/lib/navigation/use-scroll.mts @@ -0,0 +1,31 @@ +import { useKeypress, isUpKey, isDownKey } from '../../index.mjs'; +import { Activatable } from '../types.mjs'; +import { index } from '../utils.mjs'; +import { Selectable } from './types.mjs'; + +type ScrollOptions = Selectable & + Activatable & { + /** Allows wrapping on either sides of the list on navigation. True by default. */ + loop?: boolean; + }; + +export const useScroll = ({ + items, + selectable, + active, + setActive, + loop = true, +}: ScrollOptions) => { + useKeypress((key) => { + if (!loop && active === 0 && isUpKey(key)) return; + if (active === items.length - 1 && isDownKey(key)) return; + if (isUpKey(key) || isDownKey(key)) { + const offset = isUpKey(key) ? -1 : 1; + let next = active; + do { + next = index(items.length)(next + offset); + } while (!selectable(items[next]!)); + setActive(next); + } + }); +}; diff --git a/packages/core/src/lib/navigation/use-speed-dial.mts b/packages/core/src/lib/navigation/use-speed-dial.mts new file mode 100644 index 000000000..08b878cc4 --- /dev/null +++ b/packages/core/src/lib/navigation/use-speed-dial.mts @@ -0,0 +1,20 @@ +import { useKeypress, isNumberKey } from '../../index.mjs'; +import { Activatable } from '../types.mjs'; +import { Selectable } from './types.mjs'; + +type SpeedDialOptions = Pick, 'setActive'> & Selectable; + +export const useSpeedDial = ({ + items, + selectable, + setActive, +}: SpeedDialOptions) => { + useKeypress((key) => { + if (isNumberKey(key)) { + const index = Number(key.name) - 1; + if (items[index] != null && selectable(items[index]!)) { + setActive(index); + } + } + }); +}; diff --git a/packages/core/src/lib/pagination/index.mts b/packages/core/src/lib/pagination/index.mts index 2f026bed2..dcd575cbe 100644 --- a/packages/core/src/lib/pagination/index.mts +++ b/packages/core/src/lib/pagination/index.mts @@ -1,2 +1,2 @@ -export * from './types.mjs'; +export type { Paged, Page } from './types.mjs'; export * from './use-pagination.mjs'; diff --git a/packages/core/src/lib/pagination/types.mts b/packages/core/src/lib/pagination/types.mts index ebd11fd1e..6371e9e4f 100644 --- a/packages/core/src/lib/pagination/types.mts +++ b/packages/core/src/lib/pagination/types.mts @@ -1,38 +1,21 @@ -type F = (...args: A) => B; -type UnaryF = F<[T], R>; -type Action = UnaryF; +import { Activatable, HasSeveralOrdered, UnaryF } from '../types.mjs'; +/** Represents an item that's part of a page */ export type Paged = { item: T; - active?: boolean; index: number; + active?: boolean; }; -export type PageOptions = { - pageSize: number; - /** Allows wrapping on either sides of the list on navigation. */ - loop: boolean; - /** Allows quickly navigating to items 1-9 by pressing the number keys. */ - speedDial: boolean; -}; - -export type Activatable = { - active: number; - setActive: Action; -}; - -export type Selectable = { - items: readonly T[]; - selectable: UnaryF; +export type Options = HasSeveralOrdered & { + /** A function that renders an item as part of a page */ + render: UnaryF, string>; + /** The size of the page. `7` if unspecified. */ + pageSize?: number; + /** Allows creating an infinitely looping list. `true` if unspecified. */ + loop?: boolean; }; -export type Navigable = PageOptions & Selectable & Activatable; - -export type Pagination = Partial & - Selectable & { - render: UnaryF, string>; - }; - -export type Page = Activatable & { +export type Page = Activatable & { contents: string; }; diff --git a/packages/core/src/lib/pagination/use-navigation.mts b/packages/core/src/lib/pagination/use-navigation.mts deleted file mode 100644 index 3871c1f88..000000000 --- a/packages/core/src/lib/pagination/use-navigation.mts +++ /dev/null @@ -1,35 +0,0 @@ -import { useKeypress, isUpKey, isDownKey, isNumberKey } from '../../index.mjs'; -import { index } from '../utils.mjs'; -import { Navigable } from './types.mjs'; - -export const useNavigation = ({ - items, - selectable, - active, - setActive, - speedDial, - loop, -}: Navigable) => { - useKeypress((key) => { - if ( - !loop && - ((active === 0 && isUpKey(key)) || (active === items.length - 1 && isDownKey(key))) - ) - return; - if (isUpKey(key) || isDownKey(key)) { - const offset = isUpKey(key) ? -1 : 1; - let next = active; - do { - next = index(items.length)(next + offset); - } while (!selectable(items[next]!)); - setActive(next); - return; - } - if (speedDial && isNumberKey(key)) { - const index = Number(key.name) - 1; - if (items[index] != null && selectable(items[index]!)) { - setActive(index); - } - } - }); -}; diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index a444037e6..08bd3a1dc 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -1,19 +1,16 @@ import chalk from 'chalk'; import { useState, useRef, context } from '../../index.mjs'; import { finite, infinite } from './position.mjs'; -import { Pagination, Page } from './types.mjs'; +import { Options, Page } from './types.mjs'; import { lines } from './lines.mjs'; import cliWidth from 'cli-width'; -import { useNavigation } from './use-navigation.mjs'; export const usePagination = ({ items, - selectable, render, pageSize = 7, loop = true, - speedDial = true, -}: Pagination): Page => { +}: Options): Page => { const { rl } = context.getStore(); const width = cliWidth({ defaultWidth: 80, output: rl.output }); const state = useRef({ @@ -21,7 +18,6 @@ export const usePagination = ({ lastActive: 0, }); const [active, setActive] = useState(0); - useNavigation({ items, selectable, pageSize, active, setActive, loop, speedDial }); const position = (loop ? infinite : finite)( { active: { current: active, previous: state.current.lastActive }, diff --git a/packages/core/src/lib/types.mts b/packages/core/src/lib/types.mts new file mode 100644 index 000000000..b573d4c21 --- /dev/null +++ b/packages/core/src/lib/types.mts @@ -0,0 +1,12 @@ +export type F = (...args: A) => B; +export type UnaryF = F<[T], R>; +export type Action = UnaryF; + +export type Activatable = { + active: T; + setActive: Action; +}; + +export type HasSeveralOrdered = { + items: readonly T[]; +}; diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 436342f1b..54bdc7485 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -4,6 +4,8 @@ import { useKeypress, usePrefix, usePagination, + useScroll, + useSpeedDial, useRef, isEnterKey, Separator, @@ -26,22 +28,25 @@ export default createPrompt( config: SelectConfig, done: (value: Value) => void, ): string => { - const { choices } = config; - if (!choices.some(selectable)) { + const { choices: items, loop, pageSize } = config; + if (!items.some(selectable)) { throw new Error('[select prompt] No selectable choices. All choices are disabled.'); } const firstRender = useRef(true); const prefix = usePrefix(); const [status, setStatus] = useState('pending'); - const { contents, active } = usePagination>({ - items: choices, + const { contents, active, setActive } = usePagination>({ + items, render, - selectable, - pageSize: config.pageSize, - loop: config.loop, + pageSize, + loop, }); - const choice = choices[active] as Choice; + useSpeedDial({ items, selectable, setActive }); + useScroll({ items, selectable, active, setActive, loop }); + + const choice = items[active] as Choice; + useKeypress((key) => { if (isEnterKey(key)) { setStatus('done'); From 8a6605a9ec9e3b6c29adb49bb4cae7ea68ca3b45 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 18:28:20 +0100 Subject: [PATCH 33/94] Add some help text --- packages/core/src/lib/navigation/use-scroll.mts | 3 +++ packages/core/src/lib/navigation/use-speed-dial.mts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/core/src/lib/navigation/use-scroll.mts b/packages/core/src/lib/navigation/use-scroll.mts index aa6f28978..8fa81c366 100644 --- a/packages/core/src/lib/navigation/use-scroll.mts +++ b/packages/core/src/lib/navigation/use-scroll.mts @@ -9,6 +9,9 @@ type ScrollOptions = Selectable & loop?: boolean; }; +/** + * Allows scrolling through a list of items with an active cursor + */ export const useScroll = ({ items, selectable, diff --git a/packages/core/src/lib/navigation/use-speed-dial.mts b/packages/core/src/lib/navigation/use-speed-dial.mts index 08b878cc4..1cafb159f 100644 --- a/packages/core/src/lib/navigation/use-speed-dial.mts +++ b/packages/core/src/lib/navigation/use-speed-dial.mts @@ -4,6 +4,9 @@ import { Selectable } from './types.mjs'; type SpeedDialOptions = Pick, 'setActive'> & Selectable; +/** + * Allows quickly selecting an item from 1-9 by pressing a number key. + */ export const useSpeedDial = ({ items, selectable, From 03af08dff8fe80e9c84e11a7157877f00a3c246e Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 18:29:46 +0100 Subject: [PATCH 34/94] Simplify --- packages/core/src/lib/navigation/use-speed-dial.mts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/core/src/lib/navigation/use-speed-dial.mts b/packages/core/src/lib/navigation/use-speed-dial.mts index 1cafb159f..380ac5135 100644 --- a/packages/core/src/lib/navigation/use-speed-dial.mts +++ b/packages/core/src/lib/navigation/use-speed-dial.mts @@ -5,7 +5,7 @@ import { Selectable } from './types.mjs'; type SpeedDialOptions = Pick, 'setActive'> & Selectable; /** - * Allows quickly selecting an item from 1-9 by pressing a number key. + * Allows quickly selecting items from 1-9 by pressing a number key. */ export const useSpeedDial = ({ items, @@ -13,11 +13,9 @@ export const useSpeedDial = ({ setActive, }: SpeedDialOptions) => { useKeypress((key) => { - if (isNumberKey(key)) { - const index = Number(key.name) - 1; - if (items[index] != null && selectable(items[index]!)) { - setActive(index); - } - } + if (!isNumberKey(key)) return; + const index = Number(key.name) - 1; + if (items[index] == null || !selectable(items[index]!)) return; + setActive(index); }); }; From e315475c493797c6ded764d33daad8b21558c976 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 19:13:44 +0100 Subject: [PATCH 35/94] Add some help text --- packages/core/src/lib/pagination/lines.mts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 773563546..0338b8efe 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -10,6 +10,12 @@ type Inputs = { pageSize: number; }; +/** + * 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. + * @returns The rendered lines + */ export const lines = ({ items, width, From b73a2a3f124477ccbb8a81970c4835e855d6da84 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 30 Aug 2023 22:17:18 +0100 Subject: [PATCH 36/94] Fix wrap-around --- packages/core/src/lib/navigation/use-scroll.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/lib/navigation/use-scroll.mts b/packages/core/src/lib/navigation/use-scroll.mts index 8fa81c366..7057ab4a3 100644 --- a/packages/core/src/lib/navigation/use-scroll.mts +++ b/packages/core/src/lib/navigation/use-scroll.mts @@ -21,7 +21,7 @@ export const useScroll = ({ }: ScrollOptions) => { useKeypress((key) => { if (!loop && active === 0 && isUpKey(key)) return; - if (active === items.length - 1 && isDownKey(key)) return; + if (!loop && active === items.length - 1 && isDownKey(key)) return; if (isUpKey(key) || isDownKey(key)) { const offset = isUpKey(key) ? -1 : 1; let next = active; From dca57b0be985498e5c8542a066c6e339d416b14f Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 2 Sep 2023 00:12:09 +0100 Subject: [PATCH 37/94] Add some specifications for multi-line items --- .../core/src/lib/pagination/lines.test.mts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 packages/core/src/lib/pagination/lines.test.mts diff --git a/packages/core/src/lib/pagination/lines.test.mts b/packages/core/src/lib/pagination/lines.test.mts new file mode 100644 index 000000000..9ed8aa2ba --- /dev/null +++ b/packages/core/src/lib/pagination/lines.test.mts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import { lines } from './lines.mjs'; +import { Paged } from './types.mjs'; + +describe('pagination', () => { + describe('lines', () => { + type Item = { + value: number; + }; + const items: Item[] = [1, 2, 3, 4].map((value) => ({ value })); + const pageSize = 5; + const active = 2; + const position = 2; + const width = 20; + + const renderLines = + (count: number) => + ({ index, item: { value }, active }: Paged): string => + new Array(count) + .fill(0) + .map( + (_, i) => + `${ + i === 0 ? `${active ? '>' : ' '} ${(index + 1).toString()}.` : ' ' + }${value} line ${i + 1}`, + ) + .join('\n'); + + describe('given the active item can be rendered completely at given position', () => { + const render = renderLines(3); + + it('should return expected pointer', () => { + const expected = [ + ' 2 line 2', + ' 2 line 3', + '> 3.3 line 1', + ' 3 line 2', + ' 3 line 3', + ]; + const result = lines({ + items, + active, + pageSize, + position, + render, + width, + }); + expect(result).to.deep.equal(expected); + }); + }); + + describe('given the active item can be rendered completely only at earlier position', () => { + const render = renderLines(4); + + it('should return expected pointer', () => { + const expected = [ + ' 2 line 2', + '> 3.3 line 1', + ' 3 line 2', + ' 3 line 3', + ' 3 line 4', + ]; + const result = lines({ + items, + active, + pageSize, + position, + render, + width, + }); + expect(result).to.deep.equal(expected); + }); + }); + + describe('given the active item can be rendered completely only at top', () => { + const render = renderLines(5); + + it('should return expected pointer', () => { + const expected = [ + '> 3.3 line 1', + ' 3 line 2', + ' 3 line 3', + ' 3 line 4', + ' 3 line 5', + ]; + const result = lines({ + items, + active, + pageSize, + position, + render, + width, + }); + expect(result).to.deep.equal(expected); + }); + }); + + describe('given the active item cannot be rendered completely at any position', () => { + const render = renderLines(6); + + it('should return expected pointer', () => { + const expected = [ + '> 3.3 line 1', + ' 3 line 2', + ' 3 line 3', + ' 3 line 4', + ' 3 line 5', + ]; + const result = lines({ + items, + active, + pageSize, + position, + render, + width, + }); + expect(result).to.deep.equal(expected); + }); + }); + }); +}); From 4e57791e5dca0345b62bc8d83936b8a28c7db910 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 2 Sep 2023 00:25:38 +0100 Subject: [PATCH 38/94] Activate the first selectable option by default --- packages/checkbox/src/index.mts | 9 ++++++++- packages/select/select.test.mts | 2 +- packages/select/src/index.mts | 8 ++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index dde61a916..8d9a79cb3 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -16,6 +16,7 @@ import chalk from 'chalk'; import ansiEscapes from 'ansi-escapes'; import { render } from './render.mjs'; import { selectable, Item, Choice, toggle, check } from './choice.mjs'; +import { useRef } from '@inquirer/core'; type Config = { prefix?: string; @@ -32,7 +33,7 @@ export default createPrompt( done: (value: Array) => void, ): string => { const { prefix = usePrefix(), instructions, pageSize, loop, choices } = config; - + const firstRender = useRef(true); const [status, setStatus] = useState('pending'); const [items, setItems] = useState>>( choices.map((choice) => ({ ...choice })), @@ -45,6 +46,12 @@ export default createPrompt( pageSize, loop, }); + if (firstRender.current) { + firstRender.current = false; + const selected = items.findIndex(selectable); + if (selected < 0) throw new Error(`[checkbox prompt] Nothing selectable`); + setActive(selected); + } useSpeedDial({ items, selectable, setActive }); useScroll({ items, selectable, active, setActive, loop }); diff --git a/packages/select/select.test.mts b/packages/select/select.test.mts index 450783ad2..219dfdfb4 100644 --- a/packages/select/select.test.mts +++ b/packages/select/select.test.mts @@ -299,7 +299,7 @@ describe('select prompt', () => { }); await expect(answer).rejects.toThrowErrorMatchingInlineSnapshot( - '"[select prompt] No selectable choices. All choices are disabled."', + '"[select prompt] No selectable choices."', ); }); diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 54bdc7485..58cd0ad25 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -29,9 +29,6 @@ export default createPrompt( done: (value: Value) => void, ): string => { const { choices: items, loop, pageSize } = config; - if (!items.some(selectable)) { - throw new Error('[select prompt] No selectable choices. All choices are disabled.'); - } const firstRender = useRef(true); const prefix = usePrefix(); @@ -56,8 +53,11 @@ export default createPrompt( let message: string = chalk.bold(config.message); if (firstRender.current) { - message += chalk.dim(' (Use arrow keys)'); firstRender.current = false; + message += chalk.dim(' (Use arrow keys)'); + const selected = items.findIndex(selectable); + if (selected < 0) throw new Error('[select prompt] No selectable choices.'); + setActive(selected); } if (status === 'done') { From 0775e0a78c78e8f21f76d98b3ce2d64689bc581b Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Mon, 4 Sep 2023 16:46:19 -0400 Subject: [PATCH 39/94] Dumb down lines.test.mts --- .../core/src/lib/pagination/lines.test.mts | 182 ++++++++---------- 1 file changed, 84 insertions(+), 98 deletions(-) diff --git a/packages/core/src/lib/pagination/lines.test.mts b/packages/core/src/lib/pagination/lines.test.mts index ef5634931..ddcd34528 100644 --- a/packages/core/src/lib/pagination/lines.test.mts +++ b/packages/core/src/lib/pagination/lines.test.mts @@ -2,120 +2,106 @@ import { describe, it, expect } from 'vitest'; import { lines } from './lines.mjs'; import type { Paged } from './types.mjs'; -describe('pagination', () => { - describe('lines', () => { - type Item = { - value: number; - }; - const items: Item[] = [1, 2, 3, 4].map((value) => ({ value })); - const pageSize = 5; - const active = 2; - const position = 2; - const width = 20; +describe('lines(...)', () => { + const items = [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }]; - const renderLines = - (count: number) => - ({ index, item: { value }, active: cursor }: Paged): string => - new Array(count) - .fill(0) - .map( - (_, i) => - `${ - i === 0 ? `${cursor ? '>' : ' '} ${(index + 1).toString()}.` : ' ' - }${value} line ${i + 1}`, - ) - .join('\n'); + const renderLines = + (count: number) => + ({ index, item: { value }, active: cursor }: Paged<{ value: number }>): string => + new Array(count) + .fill(0) + .map((_, i) => { + const pointer = cursor ? '>' : ' '; + const prefix = i === 0 ? `${pointer} ${(index + 1).toString()}.` : ' '; + return `${prefix}${value} line ${i + 1}`; + }) + .join('\n'); - describe('given the active item can be rendered completely at given position', () => { - const render = renderLines(3); + describe('given the active item can be rendered completely at given position', () => { + const render = renderLines(3); - it('should return expected pointer', () => { - const expected = [ - ' 2 line 2', - ' 2 line 3', - '> 3.3 line 1', - ' 3 line 2', - ' 3 line 3', - ]; - const result = lines({ - items, - active, - pageSize, - position, - render, - width, - }); - expect(result).to.deep.equal(expected); + it('should return expected pointer', () => { + const result = lines({ + items, + active: 2, + pageSize: 5, + position: 2, + render, + width: 20, }); + expect(result).toEqual([ + ' 2 line 2', + ' 2 line 3', + '> 3.3 line 1', + ' 3 line 2', + ' 3 line 3', + ]); }); + }); - describe('given the active item can be rendered completely only at earlier position', () => { - const render = renderLines(4); + describe('given the active item can be rendered completely only at earlier position', () => { + const render = renderLines(4); - it('should return expected pointer', () => { - const expected = [ - ' 2 line 2', - '> 3.3 line 1', - ' 3 line 2', - ' 3 line 3', - ' 3 line 4', - ]; - const result = lines({ - items, - active, - pageSize, - position, - render, - width, - }); - expect(result).to.deep.equal(expected); + it('should return expected pointer', () => { + const result = lines({ + items, + active: 2, + pageSize: 5, + position: 2, + render, + width: 20, }); + expect(result).toEqual([ + ' 2 line 2', + '> 3.3 line 1', + ' 3 line 2', + ' 3 line 3', + ' 3 line 4', + ]); }); + }); - describe('given the active item can be rendered completely only at top', () => { - const render = renderLines(5); + describe('given the active item can be rendered completely only at top', () => { + const render = renderLines(5); - it('should return expected pointer', () => { - const expected = [ - '> 3.3 line 1', - ' 3 line 2', - ' 3 line 3', - ' 3 line 4', - ' 3 line 5', - ]; - const result = lines({ - items, - active, - pageSize, - position, - render, - width, - }); - expect(result).to.deep.equal(expected); + it('should return expected pointer', () => { + const result = lines({ + items, + active: 2, + pageSize: 5, + position: 2, + render, + width: 20, }); + expect(result).toEqual([ + '> 3.3 line 1', + ' 3 line 2', + ' 3 line 3', + ' 3 line 4', + ' 3 line 5', + ]); }); + }); - describe('given the active item cannot be rendered completely at any position', () => { - const render = renderLines(6); + describe('given the active item cannot be rendered completely at any position', () => { + const render = renderLines(6); - it('should return expected pointer', () => { - const expected = [ - '> 3.3 line 1', - ' 3 line 2', - ' 3 line 3', - ' 3 line 4', - ' 3 line 5', - ]; - const result = lines({ - items, - active, - pageSize, - position, - render, - width, - }); - expect(result).to.deep.equal(expected); + it('should return expected pointer', () => { + const result = lines({ + items, + active: 2, + pageSize: 5, + position: 2, + render, + width: 20, }); + expect(result).toEqual([ + '> 3.3 line 1', + ' 3 line 2', + ' 3 line 3', + ' 3 line 4', + ' 3 line 5', + ]); }); }); }); From c08ade9f21b53cdf042987e72dbb782264663c8f Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 5 Sep 2023 00:10:53 +0100 Subject: [PATCH 40/94] Rename Paged to Layout --- packages/checkbox/src/render.mts | 4 ++-- packages/core/src/lib/pagination/index.mts | 2 +- packages/core/src/lib/pagination/lines.mts | 4 ++-- packages/core/src/lib/pagination/lines.test.mts | 4 ++-- packages/core/src/lib/pagination/types.mts | 6 +++--- packages/select/src/render.mts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/checkbox/src/render.mts b/packages/checkbox/src/render.mts index 6a58569b2..2728a0181 100644 --- a/packages/checkbox/src/render.mts +++ b/packages/checkbox/src/render.mts @@ -1,10 +1,10 @@ -import { Paged, Separator } from '@inquirer/core'; +import { type Layout, Separator } from '@inquirer/core'; import type {} from '@inquirer/type'; import chalk from 'chalk'; import figures from 'figures'; import type { Item } from './choice.mjs'; -export const render = ({ item, active }: Paged>) => { +export const render = ({ item, active }: Layout>) => { if (Separator.isSeparator(item)) { return ` ${item.separator}`; } diff --git a/packages/core/src/lib/pagination/index.mts b/packages/core/src/lib/pagination/index.mts index dcd575cbe..028a38193 100644 --- a/packages/core/src/lib/pagination/index.mts +++ b/packages/core/src/lib/pagination/index.mts @@ -1,2 +1,2 @@ -export type { Paged, Page } from './types.mjs'; +export type { Layout, Page } from './types.mjs'; export * from './use-pagination.mjs'; diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 14ed32c91..203f8c43c 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -1,10 +1,10 @@ import { rotate, splitLines } from '../utils.mjs'; -import { Paged } from './types.mjs'; +import { Layout } from './types.mjs'; type Inputs = { items: readonly T[]; width: number; - render: (paged: Paged) => string; + render: (layout: Layout) => string; active: number; position: number; pageSize: number; diff --git a/packages/core/src/lib/pagination/lines.test.mts b/packages/core/src/lib/pagination/lines.test.mts index ddcd34528..f3d337525 100644 --- a/packages/core/src/lib/pagination/lines.test.mts +++ b/packages/core/src/lib/pagination/lines.test.mts @@ -1,13 +1,13 @@ import { describe, it, expect } from 'vitest'; import { lines } from './lines.mjs'; -import type { Paged } from './types.mjs'; +import type { Layout } from './types.mjs'; describe('lines(...)', () => { const items = [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }]; const renderLines = (count: number) => - ({ index, item: { value }, active: cursor }: Paged<{ value: number }>): string => + ({ index, item: { value }, active: cursor }: Layout<{ value: number }>): string => new Array(count) .fill(0) .map((_, i) => { diff --git a/packages/core/src/lib/pagination/types.mts b/packages/core/src/lib/pagination/types.mts index 4ceb8c3f8..3369be84f 100644 --- a/packages/core/src/lib/pagination/types.mts +++ b/packages/core/src/lib/pagination/types.mts @@ -1,7 +1,7 @@ import type { Activatable, HasSeveralOrdered, UnaryF } from '../types.mjs'; -/** Represents an item that's part of a page */ -export type Paged = { +/** Represents an item that's part of a layout, about to be rendered */ +export type Layout = { item: T; index: number; active?: boolean; @@ -9,7 +9,7 @@ export type Paged = { export type Options = HasSeveralOrdered & { /** A function that renders an item as part of a page */ - render: UnaryF, string>; + render: UnaryF, string>; /** The size of the page. `7` if unspecified. */ pageSize?: number; /** Allows creating an infinitely looping list. `true` if unspecified. */ diff --git a/packages/select/src/render.mts b/packages/select/src/render.mts index 368269abb..0ad015cd7 100644 --- a/packages/select/src/render.mts +++ b/packages/select/src/render.mts @@ -1,9 +1,9 @@ -import { Paged, Separator } from '@inquirer/core'; +import { type Layout, Separator } from '@inquirer/core'; import chalk from 'chalk'; import figures from 'figures'; import { Item } from './choice.mjs'; -export const render = ({ item, active }: Paged>) => { +export const render = ({ item, active }: Layout>) => { if (Separator.isSeparator(item)) { return ` ${item.separator}`; } From cf14edbc2a986195d112e93250ff7507516ed6f0 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 5 Sep 2023 00:11:04 +0100 Subject: [PATCH 41/94] Use slice --- packages/core/src/lib/pagination/lines.mts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 203f8c43c..fe7bd294d 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -28,15 +28,10 @@ export const lines = ({ const indexed = items.map((item, index) => ({ item, index })); const slice = rotate(active - position)(indexed).slice(0, pageSize); - const previous = slice - .filter((_, i) => i < position) - .map(render) - .flatMap(split); + const previous = slice.slice(0, position).map(render).flatMap(split); const current = split(render({ ...slice[position]!, active: true })); - const rest = slice - .filter((_, i) => i > position) - .map(render) - .flatMap(split); + const next = position + 1; + const rest = slice.slice(next).map(render).flatMap(split); const output = previous.concat(current).concat(rest); return rotate(previous.length - position)(output).slice(0, pageSize); From 76476c91c992cdf2cc321d334fe02f62aa1bfa4a Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 5 Sep 2023 00:16:54 +0100 Subject: [PATCH 42/94] Make active mandatory --- packages/core/src/lib/pagination/lines.mts | 14 +++++++------- packages/core/src/lib/pagination/types.mts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index fe7bd294d..a793e36b3 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -26,13 +26,13 @@ export const lines = ({ }: Inputs): string[] => { const split = splitLines(width); - const indexed = items.map((item, index) => ({ item, index })); - const slice = rotate(active - position)(indexed).slice(0, pageSize); - const previous = slice.slice(0, position).map(render).flatMap(split); - const current = split(render({ ...slice[position]!, active: true })); + const indexed = items.map((item, index) => ({ item, index, active: index === active })); + const picked = rotate(active - position)(indexed).slice(0, pageSize); + const previous = picked.slice(0, position).map(render).flatMap(split); + const current = split(render({ ...picked[position]! })); const next = position + 1; - const rest = slice.slice(next).map(render).flatMap(split); + const rest = picked.slice(next).map(render).flatMap(split); - const output = previous.concat(current).concat(rest); - return rotate(previous.length - position)(output).slice(0, pageSize); + const page = previous.concat(current).concat(rest); + return rotate(previous.length - position)(page).slice(0, pageSize); }; diff --git a/packages/core/src/lib/pagination/types.mts b/packages/core/src/lib/pagination/types.mts index 3369be84f..8f2dc26ee 100644 --- a/packages/core/src/lib/pagination/types.mts +++ b/packages/core/src/lib/pagination/types.mts @@ -4,7 +4,7 @@ import type { Activatable, HasSeveralOrdered, UnaryF } from '../types.mjs'; export type Layout = { item: T; index: number; - active?: boolean; + active: boolean; }; export type Options = HasSeveralOrdered & { From 404049441a31f7f67ff396f5b5b3011beb2aefe7 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 5 Sep 2023 00:18:44 +0100 Subject: [PATCH 43/94] Simplify --- packages/core/src/lib/pagination/lines.mts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index a793e36b3..82cbe794e 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -26,13 +26,19 @@ export const lines = ({ }: Inputs): string[] => { const split = splitLines(width); - const indexed = items.map((item, index) => ({ item, index, active: index === active })); - const picked = rotate(active - position)(indexed).slice(0, pageSize); + const layouts = items.map>((item, index) => ({ + item, + index, + active: index === active, + })); + const picked = rotate(active - position)(layouts).slice(0, pageSize); const previous = picked.slice(0, position).map(render).flatMap(split); const current = split(render({ ...picked[position]! })); - const next = position + 1; - const rest = picked.slice(next).map(render).flatMap(split); + const next = picked + .slice(position + 1) + .map(render) + .flatMap(split); - const page = previous.concat(current).concat(rest); + const page = previous.concat(current).concat(next); return rotate(previous.length - position)(page).slice(0, pageSize); }; From 23b7b6f4e15471593b8ed47f0b129c3543d2616d Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 5 Sep 2023 00:30:18 +0100 Subject: [PATCH 44/94] Fix expectation --- packages/core/src/lib/pagination/lines.test.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/lib/pagination/lines.test.mts b/packages/core/src/lib/pagination/lines.test.mts index f3d337525..7f11c8c35 100644 --- a/packages/core/src/lib/pagination/lines.test.mts +++ b/packages/core/src/lib/pagination/lines.test.mts @@ -52,7 +52,7 @@ describe('lines(...)', () => { width: 20, }); expect(result).toEqual([ - ' 2 line 2', + ' 2 line 4', '> 3.3 line 1', ' 3 line 2', ' 3 line 3', From e766082463d20195a8cc99938c299387f93a7717 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 5 Sep 2023 00:30:49 +0100 Subject: [PATCH 45/94] Render as many lines of active item as possible --- packages/core/src/lib/pagination/lines.mts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 82cbe794e..ef874665f 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -21,7 +21,7 @@ export const lines = ({ width, render, active, - position, + position: requested, pageSize, }: Inputs): string[] => { const split = splitLines(width); @@ -31,14 +31,18 @@ export const lines = ({ index, active: index === active, })); - const picked = rotate(active - position)(layouts).slice(0, pageSize); - const previous = picked.slice(0, position).map(render).flatMap(split); - const current = split(render({ ...picked[position]! })); + const picked = rotate(active - requested)(layouts).slice(0, pageSize); + const previous = picked.slice(0, requested).map(render).flatMap(split); + const current = split(render({ ...picked[requested]! })); const next = picked - .slice(position + 1) + .slice(requested + 1) .map(render) .flatMap(split); const page = previous.concat(current).concat(next); + const position = + requested + current.length <= pageSize + ? requested + : Math.max(0, pageSize - current.length); return rotate(previous.length - position)(page).slice(0, pageSize); }; From 1b59e239a592eddc497fe33fe3669c7d24155528 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 5 Sep 2023 00:32:47 +0100 Subject: [PATCH 46/94] Add help text --- packages/core/src/lib/pagination/lines.mts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index ef874665f..55143f96e 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -3,10 +3,14 @@ import { Layout } from './types.mjs'; type Inputs = { items: readonly T[]; + /** The width of a rendered line in characters. */ width: number; render: (layout: Layout) => 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; }; From eb726b3ad8f87f75e491dfcf339561e4f7acf6af Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 5 Sep 2023 00:35:56 +0100 Subject: [PATCH 47/94] Add note on rendering most lines of active item --- packages/core/src/lib/pagination/lines.mts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 55143f96e..d2aa66bda 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -17,7 +17,8 @@ type Inputs = { /** * 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. + * item renders at the provided position, while prioritizing that as many lines + * of the active item get rendered as possible. * @returns The rendered lines */ export const lines = ({ From f9821752a16e964d92d9b492ba87ba9c43216389 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 5 Sep 2023 00:44:56 +0100 Subject: [PATCH 48/94] Fix lint issues --- packages/checkbox/src/index.mts | 2 +- packages/core/src/lib/pagination/use-pagination.mts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 8d9a79cb3..923c1f780 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -10,13 +10,13 @@ import { isNumberKey, isEnterKey, Separator, + useRef, } from '@inquirer/core'; import type {} from '@inquirer/type'; import chalk from 'chalk'; import ansiEscapes from 'ansi-escapes'; import { render } from './render.mjs'; import { selectable, Item, Choice, toggle, check } from './choice.mjs'; -import { useRef } from '@inquirer/core'; type Config = { prefix?: string; diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index 2cb874f22..55d31b0e2 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -1,11 +1,11 @@ import chalk from 'chalk'; -import { finite, infinite } from './position.mjs'; -import { Options, Page } from './types.mjs'; -import { lines } from './lines.mjs'; import cliWidth from 'cli-width'; import { api } from '../hook-api.mjs'; import { useRef } from '../use-ref.mjs'; import { useState } from '../use-state.mjs'; +import { lines } from './lines.mjs'; +import { Options, Page } from './types.mjs'; +import { finite, infinite } from './position.mjs'; export const usePagination = ({ items, From ee7ad496f8a6e13bc27071060b3ff17791e4c630 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 5 Sep 2023 23:23:48 +0100 Subject: [PATCH 49/94] Remove additional utility --- packages/core/src/lib/pagination/lines.mts | 4 ++-- packages/core/src/lib/utils.mts | 23 ++++++++-------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index d2aa66bda..c42bf5198 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -1,4 +1,4 @@ -import { rotate, splitLines } from '../utils.mjs'; +import { breakLines, rotate } from '../utils.mjs'; import { Layout } from './types.mjs'; type Inputs = { @@ -29,7 +29,7 @@ export const lines = ({ position: requested, pageSize, }: Inputs): string[] => { - const split = splitLines(width); + const split = (content: string) => breakLines(content, width).split('\n'); const layouts = items.map>((item, index) => ({ item, diff --git a/packages/core/src/lib/utils.mts b/packages/core/src/lib/utils.mts index bbb227ac8..b4b22e424 100644 --- a/packages/core/src/lib/utils.mts +++ b/packages/core/src/lib/utils.mts @@ -1,19 +1,5 @@ import wrapAnsi from 'wrap-ansi'; -/** - * Split a string into lines at specific width, in addition to line breaks. This - * function is ANSI code friendly and it'll ignore invisible codes during width - * calculation. - */ -export const splitLines = - (width: number) => - (content: string): string[] => - content.split('\n').flatMap((line) => - wrapAnsi(line, width, { trim: false, hard: true }) - .split('\n') - .map((str) => str.trimEnd()), - ); - /** * Force line returns at specific width. This function is ANSI code friendly and it'll * ignore invisible codes during width calculation. @@ -22,7 +8,14 @@ export const splitLines = * @return {string} */ export const breakLines = (content: string, width: number): string => - splitLines(width)(content).join('\n'); + content + .split('\n') + .flatMap((line) => + wrapAnsi(line, width, { trim: false, hard: true }) + .split('\n') + .map((str) => str.trimEnd()), + ) + .join('\n'); /** * Creates a 0-based index out of an integer, wrapping around if necessary. From 6188e30caf0a67bb6948426b3885c610eee991bf Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 5 Sep 2023 23:51:28 +0100 Subject: [PATCH 50/94] Update internals --- packages/core/src/lib/pagination/use-pagination.mts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index 55d31b0e2..8f0cf94d8 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import cliWidth from 'cli-width'; -import { api } from '../hook-api.mjs'; +import { readline } from '../hook-engine.mjs'; import { useRef } from '../use-ref.mjs'; import { useState } from '../use-state.mjs'; import { lines } from './lines.mjs'; @@ -13,7 +13,7 @@ export const usePagination = ({ pageSize = 7, loop = true, }: Options): Page => { - const { rl } = api.getStore(); + const rl = readline(); const width = cliWidth({ defaultWidth: 80, output: rl.output }); const state = useRef({ position: 0, From bff847ef310ec41f2d659eef3d191ea290beaacd Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 5 Sep 2023 23:51:39 +0100 Subject: [PATCH 51/94] Render only as much as required --- packages/core/src/lib/pagination/lines.mts | 50 ++++++++++++++++------ 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index c42bf5198..4e8c0224a 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -36,18 +36,44 @@ export const lines = ({ index, active: index === active, })); - const picked = rotate(active - requested)(layouts).slice(0, pageSize); - const previous = picked.slice(0, requested).map(render).flatMap(split); - const current = split(render({ ...picked[requested]! })); - const next = picked - .slice(requested + 1) - .map(render) - .flatMap(split); - - const page = previous.concat(current).concat(next); + const layoutsInPage = rotate(active - requested)(layouts).slice(0, pageSize); + + // Create a blank array of lines for the page + const page = new Array(pageSize); + + // Render the active item to decide the position + const activeLines = split(render(layoutsInPage[requested])); const position = - requested + current.length <= pageSize + requested + activeLines.length <= pageSize ? requested - : Math.max(0, pageSize - current.length); - return rotate(previous.length - position)(page).slice(0, pageSize); + : Math.max(0, pageSize - activeLines.length); + + // Render the lines of the active item into the page + activeLines + .slice(0, pageSize) + .forEach((line, index) => (page[position + index] = line)); + + // Fill the next lines + let lineNumber = position + activeLines.length; + let layoutIndex = requested + 1; + while (lineNumber < pageSize && layoutIndex < layoutsInPage.length) { + for (const line of split(render(layoutsInPage[layoutIndex]))) { + page[lineNumber++] = line; + if (lineNumber >= pageSize) break; + } + layoutIndex++; + } + + // Fill the previous lines + lineNumber = position - 1; + layoutIndex = requested - 1; + while (lineNumber >= 0 && layoutIndex >= 0) { + for (const line of split(render(layoutsInPage[layoutIndex])).reverse()) { + page[lineNumber--] = line; + if (lineNumber < 0) break; + } + layoutIndex--; + } + + return page.slice(0, pageSize).filter((line) => typeof line === 'string'); }; From 5e382bbc033b0653f7b8a82159553877bc0f07a1 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Tue, 5 Sep 2023 23:55:11 +0100 Subject: [PATCH 52/94] Fix eslint error --- packages/core/src/lib/pagination/lines.mts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 4e8c0224a..71c901f5b 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -49,9 +49,9 @@ export const lines = ({ : Math.max(0, pageSize - activeLines.length); // Render the lines of the active item into the page - activeLines - .slice(0, pageSize) - .forEach((line, index) => (page[position + index] = line)); + activeLines.slice(0, pageSize).forEach((line, index) => { + page[position + index] = line; + }); // Fill the next lines let lineNumber = position + activeLines.length; From 21bd90881e430062bbdb1c701713215b6f0cd1f2 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 6 Sep 2023 00:06:37 +0100 Subject: [PATCH 53/94] Add a moot check --- packages/core/src/lib/pagination/lines.mts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 71c901f5b..9b31d5746 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -42,6 +42,7 @@ export const lines = ({ const page = new Array(pageSize); // Render the active item to decide the position + if (!(requested in layoutsInPage)) throw new Error('Invalid position'); const activeLines = split(render(layoutsInPage[requested])); const position = requested + activeLines.length <= pageSize From b49779424b068910d65b75cf07289c96185f38e4 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 6 Sep 2023 00:08:28 +0100 Subject: [PATCH 54/94] Silence type check failures --- packages/core/src/lib/pagination/lines.mts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 9b31d5746..e3988dc87 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -42,8 +42,7 @@ export const lines = ({ const page = new Array(pageSize); // Render the active item to decide the position - if (!(requested in layoutsInPage)) throw new Error('Invalid position'); - const activeLines = split(render(layoutsInPage[requested])); + const activeLines = split(render(layoutsInPage[requested]!)); const position = requested + activeLines.length <= pageSize ? requested @@ -58,7 +57,7 @@ export const lines = ({ let lineNumber = position + activeLines.length; let layoutIndex = requested + 1; while (lineNumber < pageSize && layoutIndex < layoutsInPage.length) { - for (const line of split(render(layoutsInPage[layoutIndex]))) { + for (const line of split(render(layoutsInPage[layoutIndex]!))) { page[lineNumber++] = line; if (lineNumber >= pageSize) break; } @@ -69,7 +68,7 @@ export const lines = ({ lineNumber = position - 1; layoutIndex = requested - 1; while (lineNumber >= 0 && layoutIndex >= 0) { - for (const line of split(render(layoutsInPage[layoutIndex])).reverse()) { + for (const line of split(render(layoutsInPage[layoutIndex]!)).reverse()) { page[lineNumber--] = line; if (lineNumber < 0) break; } From db798897b68bf14f6251a1931e2da52258ba68d3 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 6 Sep 2023 17:36:08 +0100 Subject: [PATCH 55/94] Remove unnecessary slice --- packages/core/src/lib/pagination/lines.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index e3988dc87..67971df88 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -75,5 +75,5 @@ export const lines = ({ layoutIndex--; } - return page.slice(0, pageSize).filter((line) => typeof line === 'string'); + return page.filter((line) => typeof line === 'string'); }; From 3b079665f937e7c8e5c27abb19fa307ed0dffd89 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 6 Sep 2023 22:49:24 +0100 Subject: [PATCH 56/94] Use a small helper to render lines --- packages/core/src/lib/pagination/lines.mts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 67971df88..58a2b16b4 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -30,19 +30,19 @@ export const lines = ({ pageSize, }: Inputs): string[] => { const split = (content: string) => breakLines(content, width).split('\n'); - const layouts = items.map>((item, index) => ({ item, index, active: index === active, })); const layoutsInPage = rotate(active - requested)(layouts).slice(0, pageSize); + const getLines = (index: number) => split(render(layoutsInPage[index]!)); // Create a blank array of lines for the page const page = new Array(pageSize); // Render the active item to decide the position - const activeLines = split(render(layoutsInPage[requested]!)); + const activeLines = getLines(requested); const position = requested + activeLines.length <= pageSize ? requested @@ -57,7 +57,7 @@ export const lines = ({ let lineNumber = position + activeLines.length; let layoutIndex = requested + 1; while (lineNumber < pageSize && layoutIndex < layoutsInPage.length) { - for (const line of split(render(layoutsInPage[layoutIndex]!))) { + for (const line of getLines(layoutIndex)) { page[lineNumber++] = line; if (lineNumber >= pageSize) break; } @@ -68,7 +68,7 @@ export const lines = ({ lineNumber = position - 1; layoutIndex = requested - 1; while (lineNumber >= 0 && layoutIndex >= 0) { - for (const line of split(render(layoutsInPage[layoutIndex]!)).reverse()) { + for (const line of getLines(layoutIndex).reverse()) { page[lineNumber--] = line; if (lineNumber < 0) break; } From 62869d8da0cdc6232f9f51d9f3b75ac9fc2aa58f Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Wed, 6 Sep 2023 22:50:12 +0100 Subject: [PATCH 57/94] Reduce active lines to page size --- packages/core/src/lib/pagination/lines.mts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 58a2b16b4..eb63105e5 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -42,14 +42,14 @@ export const lines = ({ const page = new Array(pageSize); // Render the active item to decide the position - const activeLines = getLines(requested); + const activeLines = getLines(requested).slice(0, pageSize); const position = requested + activeLines.length <= pageSize ? requested - : Math.max(0, pageSize - activeLines.length); + : pageSize - activeLines.length; // Render the lines of the active item into the page - activeLines.slice(0, pageSize).forEach((line, index) => { + activeLines.forEach((line, index) => { page[position + index] = line; }); From 087141bdce576833b445ed041444d33e2733bd3e Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 09:10:46 +0100 Subject: [PATCH 58/94] Uncurry --- packages/core/src/lib/navigation/use-scroll.mts | 2 +- packages/core/src/lib/pagination/lines.mts | 2 +- packages/core/src/lib/utils.mts | 16 +++++++++------- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/core/src/lib/navigation/use-scroll.mts b/packages/core/src/lib/navigation/use-scroll.mts index 458e261ad..d861e1be4 100644 --- a/packages/core/src/lib/navigation/use-scroll.mts +++ b/packages/core/src/lib/navigation/use-scroll.mts @@ -27,7 +27,7 @@ export const useScroll = ({ const offset = isUpKey(key) ? -1 : 1; let next = active; do { - next = index(items.length)(next + offset); + next = index(items.length, next + offset); } while (!selectable(items[next]!)); setActive(next); } diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index eb63105e5..7c8247baf 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -35,7 +35,7 @@ export const lines = ({ index, active: index === active, })); - const layoutsInPage = rotate(active - requested)(layouts).slice(0, pageSize); + const layoutsInPage = rotate(active - requested, layouts).slice(0, pageSize); const getLines = (index: number) => split(render(layoutsInPage[index]!)); // Create a blank array of lines for the page diff --git a/packages/core/src/lib/utils.mts b/packages/core/src/lib/utils.mts index b4b22e424..3b7dffe94 100644 --- a/packages/core/src/lib/utils.mts +++ b/packages/core/src/lib/utils.mts @@ -19,15 +19,17 @@ export const breakLines = (content: string, width: number): string => /** * Creates a 0-based index out of an integer, wrapping around if necessary. + * @param {number} max The maximum count + * @param {number} value The value to convert to index */ -export const index = (max: number) => (value: number) => ((value % max) + max) % max; +export const index = (max: number, value: number) => ((value % max) + max) % max; /** * 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 */ -export const rotate = - (count: number) => - (items: readonly T[]): readonly T[] => { - const offset = index(items.length)(count); - return items.slice(offset).concat(items.slice(0, offset)); - }; +export const rotate = (count: number, items: readonly T[]): readonly T[] => { + const offset = index(items.length, count); + return items.slice(offset).concat(items.slice(0, offset)); +}; From c01d87243970c96e54171478cb82608ae75bc9cb Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 09:15:31 +0100 Subject: [PATCH 59/94] Use single file per component --- packages/checkbox/src/choice.mts | 22 ---------------- packages/checkbox/src/index.mts | 43 ++++++++++++++++++++++++++++++-- packages/checkbox/src/render.mts | 23 ----------------- packages/select/src/choice.mts | 14 ----------- packages/select/src/index.mts | 34 +++++++++++++++++++++++-- packages/select/src/render.mts | 21 ---------------- 6 files changed, 73 insertions(+), 84 deletions(-) delete mode 100644 packages/checkbox/src/choice.mts delete mode 100644 packages/checkbox/src/render.mts delete mode 100644 packages/select/src/choice.mts delete mode 100644 packages/select/src/render.mts diff --git a/packages/checkbox/src/choice.mts b/packages/checkbox/src/choice.mts deleted file mode 100644 index 22b3b599c..000000000 --- a/packages/checkbox/src/choice.mts +++ /dev/null @@ -1,22 +0,0 @@ -import { Separator } from '@inquirer/core'; - -export type Choice = { - name?: string; - value: Value; - disabled?: boolean | string; - checked?: boolean; - type?: never; -}; - -export type Item = Separator | Choice; - -export const selectable = (item: Item): item is Choice => - !Separator.isSeparator(item) && !item.disabled; - -export const check = - (checked: boolean) => - (item: Item): Item => - selectable(item) ? { ...item, checked } : item; - -export const toggle = (item: Item): Item => - selectable(item) ? { ...item, checked: !item.checked } : item; diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 923c1f780..164f0eec5 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -9,14 +9,53 @@ import { isSpaceKey, isNumberKey, isEnterKey, + type Layout, Separator, useRef, } from '@inquirer/core'; import type {} from '@inquirer/type'; import chalk from 'chalk'; import ansiEscapes from 'ansi-escapes'; -import { render } from './render.mjs'; -import { selectable, Item, Choice, toggle, check } from './choice.mjs'; +import figures from 'figures'; + +type Choice = { + name?: string; + value: Value; + disabled?: boolean | string; + checked?: boolean; + type?: never; +}; + +type Item = Separator | Choice; + +const selectable = (item: Item): item is Choice => + !Separator.isSeparator(item) && !item.disabled; + +const check = + (checked: boolean) => + (item: Item): Item => + selectable(item) ? { ...item, checked } : item; + +const toggle = (item: Item): Item => + selectable(item) ? { ...item, checked: !item.checked } : item; + +const render = ({ item, active }: Layout>) => { + if (Separator.isSeparator(item)) { + return ` ${item.separator}`; + } + + const line = item.name || item.value; + if (item.disabled) { + const disabledLabel = + typeof item.disabled === 'string' ? item.disabled : '(disabled)'; + return chalk.dim(`- ${line} ${disabledLabel}`); + } + + const checkbox = item.checked ? chalk.green(figures.circleFilled) : figures.circle; + const color = active ? chalk.cyan : (x: string) => x; + const prefix = active ? figures.pointer : ' '; + return color(`${prefix}${checkbox} ${line}`); +}; type Config = { prefix?: string; diff --git a/packages/checkbox/src/render.mts b/packages/checkbox/src/render.mts deleted file mode 100644 index 2728a0181..000000000 --- a/packages/checkbox/src/render.mts +++ /dev/null @@ -1,23 +0,0 @@ -import { type Layout, Separator } from '@inquirer/core'; -import type {} from '@inquirer/type'; -import chalk from 'chalk'; -import figures from 'figures'; -import type { Item } from './choice.mjs'; - -export const render = ({ item, active }: Layout>) => { - if (Separator.isSeparator(item)) { - return ` ${item.separator}`; - } - - const line = item.name || item.value; - if (item.disabled) { - const disabledLabel = - typeof item.disabled === 'string' ? item.disabled : '(disabled)'; - return chalk.dim(`- ${line} ${disabledLabel}`); - } - - const checkbox = item.checked ? chalk.green(figures.circleFilled) : figures.circle; - const color = active ? chalk.cyan : (x: string) => x; - const prefix = active ? figures.pointer : ' '; - return color(`${prefix}${checkbox} ${line}`); -}; diff --git a/packages/select/src/choice.mts b/packages/select/src/choice.mts deleted file mode 100644 index d5d0da6e4..000000000 --- a/packages/select/src/choice.mts +++ /dev/null @@ -1,14 +0,0 @@ -import { Separator } from '@inquirer/core'; - -export type Choice = { - value: Value; - name?: string; - description?: string; - disabled?: boolean | string; - type?: never; -}; - -export type Item = Separator | Choice; - -export const selectable = (item: Item): item is Choice => - !Separator.isSeparator(item) && !item.disabled; diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 81c02173a..21be8fba3 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -8,14 +8,44 @@ import { useSpeedDial, useRef, isEnterKey, + type Layout, Separator, AsyncPromptConfig, } from '@inquirer/core'; import type {} from '@inquirer/type'; import chalk from 'chalk'; import ansiEscapes from 'ansi-escapes'; -import { render } from './render.mjs'; -import { Choice, Item, selectable } from './choice.mjs'; +import figures from 'figures'; + +type Choice = { + value: Value; + name?: string; + description?: string; + disabled?: boolean | string; + type?: never; +}; + +type Item = Separator | Choice; + +const selectable = (item: Item): item is Choice => + !Separator.isSeparator(item) && !item.disabled; + +const render = ({ item, active }: Layout>) => { + if (Separator.isSeparator(item)) { + return ` ${item.separator}`; + } + + const line = item.name || item.value; + if (item.disabled) { + const disabledLabel = + typeof item.disabled === 'string' ? item.disabled : '(disabled)'; + return chalk.dim(`- ${line} ${disabledLabel}`); + } + + const color = active ? chalk.cyan : (x: string) => x; + const prefix = active ? figures.pointer : ` `; + return color(`${prefix} ${line}`); +}; type SelectConfig = AsyncPromptConfig & { choices: ReadonlyArray | Separator>; diff --git a/packages/select/src/render.mts b/packages/select/src/render.mts deleted file mode 100644 index 0ad015cd7..000000000 --- a/packages/select/src/render.mts +++ /dev/null @@ -1,21 +0,0 @@ -import { type Layout, Separator } from '@inquirer/core'; -import chalk from 'chalk'; -import figures from 'figures'; -import { Item } from './choice.mjs'; - -export const render = ({ item, active }: Layout>) => { - if (Separator.isSeparator(item)) { - return ` ${item.separator}`; - } - - const line = item.name || item.value; - if (item.disabled) { - const disabledLabel = - typeof item.disabled === 'string' ? item.disabled : '(disabled)'; - return chalk.dim(`- ${line} ${disabledLabel}`); - } - - const color = active ? chalk.cyan : (x: string) => x; - const prefix = active ? figures.pointer : ` `; - return color(`${prefix} ${line}`); -}; From ebbc609640fc4788e06855f3c7c3e9231f15f053 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 09:23:44 +0100 Subject: [PATCH 60/94] Inline types --- packages/core/src/lib/navigation/types.mts | 6 ----- .../core/src/lib/navigation/use-scroll.mts | 17 +++++++------ .../src/lib/navigation/use-speed-dial.mts | 10 +++++--- packages/core/src/lib/pagination/index.mts | 1 - packages/core/src/lib/pagination/lines.mts | 1 + packages/core/src/lib/pagination/types.mts | 21 ---------------- .../src/lib/pagination/use-pagination.mts | 25 ++++++++++++++++++- packages/core/src/lib/types.mts | 12 --------- 8 files changed, 42 insertions(+), 51 deletions(-) delete mode 100644 packages/core/src/lib/navigation/types.mts delete mode 100644 packages/core/src/lib/pagination/types.mts delete mode 100644 packages/core/src/lib/types.mts diff --git a/packages/core/src/lib/navigation/types.mts b/packages/core/src/lib/navigation/types.mts deleted file mode 100644 index 301d554b0..000000000 --- a/packages/core/src/lib/navigation/types.mts +++ /dev/null @@ -1,6 +0,0 @@ -import { HasSeveralOrdered, UnaryF } from '../types.mjs'; - -export type Selectable = HasSeveralOrdered & { - /** Returns whether an item can be selected. */ - selectable: UnaryF; -}; diff --git a/packages/core/src/lib/navigation/use-scroll.mts b/packages/core/src/lib/navigation/use-scroll.mts index d861e1be4..981fe1f4f 100644 --- a/packages/core/src/lib/navigation/use-scroll.mts +++ b/packages/core/src/lib/navigation/use-scroll.mts @@ -1,14 +1,17 @@ import { isUpKey, isDownKey } from '../key.mjs'; -import { Activatable } from '../types.mjs'; import { useKeypress } from '../use-keypress.mjs'; import { index } from '../utils.mjs'; -import { Selectable } from './types.mjs'; -type ScrollOptions = Selectable & - Activatable & { - /** Allows wrapping on either sides of the list on navigation. True by default. */ - loop?: boolean; - }; +type ScrollOptions = { + items: readonly T[]; + active: number; + /** Sets the index of the active item. */ + setActive: (active: number) => void; + /** Returns whether an item can be selected. */ + selectable: (item: T) => boolean; + /** Allows wrapping on either sides of the list on navigation. True by default. */ + loop?: boolean; +}; /** * Allows scrolling through a list of items with an active cursor diff --git a/packages/core/src/lib/navigation/use-speed-dial.mts b/packages/core/src/lib/navigation/use-speed-dial.mts index 52107b0c3..b64960ed6 100644 --- a/packages/core/src/lib/navigation/use-speed-dial.mts +++ b/packages/core/src/lib/navigation/use-speed-dial.mts @@ -1,9 +1,13 @@ import { isNumberKey } from '../key.mjs'; -import type { Activatable } from '../types.mjs'; import { useKeypress } from '../use-keypress.mjs'; -import type { Selectable } from './types.mjs'; -type SpeedDialOptions = Pick, 'setActive'> & Selectable; +type SpeedDialOptions = { + items: readonly T[]; + /** Sets the index of the active item. */ + setActive: (active: number) => void; + /** Returns whether an item can be selected. */ + selectable: (item: T) => boolean; +}; /** * Allows quickly selecting items from 1-9 by pressing a number key. diff --git a/packages/core/src/lib/pagination/index.mts b/packages/core/src/lib/pagination/index.mts index 028a38193..00411ecc5 100644 --- a/packages/core/src/lib/pagination/index.mts +++ b/packages/core/src/lib/pagination/index.mts @@ -1,2 +1 @@ -export type { Layout, Page } from './types.mjs'; export * from './use-pagination.mjs'; diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 7c8247baf..8b6db2af8 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -5,6 +5,7 @@ type Inputs = { items: readonly T[]; /** The width of a rendered line in characters. */ width: number; + /** Renders an item as part of a page. */ render: (layout: Layout) => string; /** The index of the active item in the list of items. */ active: number; diff --git a/packages/core/src/lib/pagination/types.mts b/packages/core/src/lib/pagination/types.mts deleted file mode 100644 index 8f2dc26ee..000000000 --- a/packages/core/src/lib/pagination/types.mts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Activatable, HasSeveralOrdered, UnaryF } from '../types.mjs'; - -/** Represents an item that's part of a layout, about to be rendered */ -export type Layout = { - item: T; - index: number; - active: boolean; -}; - -export type Options = HasSeveralOrdered & { - /** A function that renders an item as part of a page */ - render: UnaryF, string>; - /** The size of the page. `7` if unspecified. */ - pageSize?: number; - /** Allows creating an infinitely looping list. `true` if unspecified. */ - loop?: boolean; -}; - -export type Page = Activatable & { - contents: string; -}; diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index 8f0cf94d8..2a0708fdd 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -4,9 +4,32 @@ import { readline } from '../hook-engine.mjs'; import { useRef } from '../use-ref.mjs'; import { useState } from '../use-state.mjs'; import { lines } from './lines.mjs'; -import { Options, Page } from './types.mjs'; import { finite, infinite } from './position.mjs'; +/** Represents an item that's part of a layout, about to be rendered */ +export type Layout = { + item: T; + index: number; + active: boolean; +}; + +export type Options = { + items: readonly T[]; + /** Renders an item as part of a page. */ + render: (layout: Layout) => string; + /** The size of the page. `7` if unspecified. */ + pageSize?: number; + /** Allows creating an infinitely looping list. `true` if unspecified. */ + loop?: boolean; +}; + +export type Page = { + contents: string; + active: number; + /** Sets the index of the active item. */ + setActive: (active: number) => void; +}; + export const usePagination = ({ items, render, diff --git a/packages/core/src/lib/types.mts b/packages/core/src/lib/types.mts deleted file mode 100644 index b573d4c21..000000000 --- a/packages/core/src/lib/types.mts +++ /dev/null @@ -1,12 +0,0 @@ -export type F = (...args: A) => B; -export type UnaryF = F<[T], R>; -export type Action = UnaryF; - -export type Activatable = { - active: T; - setActive: Action; -}; - -export type HasSeveralOrdered = { - items: readonly T[]; -}; From 7eceaa9dcc3586c0c0fcd44443223994cd8135ec Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 09:26:39 +0100 Subject: [PATCH 61/94] Use ifs over nested ternary --- packages/core/src/lib/pagination/position.mts | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/core/src/lib/pagination/position.mts b/packages/core/src/lib/pagination/position.mts index 71d9e8833..726f7eb28 100644 --- a/packages/core/src/lib/pagination/position.mts +++ b/packages/core/src/lib/pagination/position.mts @@ -21,25 +21,23 @@ type PositionReducer = (info: PageInfo, pointer: number) => number; */ export const finite: PositionReducer = ({ pageSize, total, active }) => { const middle = Math.floor(pageSize / 2); - return total <= pageSize || active.current < middle - ? active.current - : active.current >= total - middle - ? active.current + pageSize - total - : middle; + if (total <= pageSize || active.current < middle) return active.current; + if (active.current >= total - middle) return active.current + pageSize - total; + return middle; }; /** * Creates the next position for the active item considering an infinitely * looping list of items to be rendered on the page. */ -export const infinite: PositionReducer = ({ active, total, pageSize }, pointer) => - total <= pageSize - ? active.current - : /** - * Move the position only when the user moves down, and when the - * navigation fits within a single page - */ - active.previous < active.current && active.current - active.previous < pageSize - ? // Limit it to the middle of the list - Math.min(Math.floor(pageSize / 2), pointer + active.current - active.previous) - : pointer; +export const infinite: PositionReducer = ({ active, total, pageSize }, pointer) => { + if (total <= pageSize) return active.current; + /** + * Move the position only when the user moves down, and when the + * navigation fits within a single page + */ + if (active.previous < active.current && active.current - active.previous < pageSize) + // Limit it to the middle of the list + return Math.min(Math.floor(pageSize / 2), pointer + active.current - active.previous); + return pointer; +}; From 6c75f1cd66b7cf497901b03f45b14021782f98f1 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 09:29:57 +0100 Subject: [PATCH 62/94] Fix imports --- packages/core/src/lib/pagination/layout.type.mts | 6 ++++++ packages/core/src/lib/pagination/lines.mts | 2 +- packages/core/src/lib/pagination/lines.test.mts | 2 +- packages/core/src/lib/pagination/use-pagination.mts | 8 +------- 4 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/lib/pagination/layout.type.mts diff --git a/packages/core/src/lib/pagination/layout.type.mts b/packages/core/src/lib/pagination/layout.type.mts new file mode 100644 index 000000000..1af065ef7 --- /dev/null +++ b/packages/core/src/lib/pagination/layout.type.mts @@ -0,0 +1,6 @@ +/** Represents an item that's part of a layout, about to be rendered */ +export type Layout = { + item: T; + index: number; + active: boolean; +}; diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 8b6db2af8..1f10d69d7 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -1,5 +1,5 @@ import { breakLines, rotate } from '../utils.mjs'; -import { Layout } from './types.mjs'; +import { type Layout } from './layout.type.mjs'; type Inputs = { items: readonly T[]; diff --git a/packages/core/src/lib/pagination/lines.test.mts b/packages/core/src/lib/pagination/lines.test.mts index 7f11c8c35..73a5f8de6 100644 --- a/packages/core/src/lib/pagination/lines.test.mts +++ b/packages/core/src/lib/pagination/lines.test.mts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { lines } from './lines.mjs'; -import type { Layout } from './types.mjs'; +import { type Layout } from './layout.type.mjs'; describe('lines(...)', () => { const items = [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }]; diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index 2a0708fdd..0f9755aab 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -5,13 +5,7 @@ import { useRef } from '../use-ref.mjs'; import { useState } from '../use-state.mjs'; import { lines } from './lines.mjs'; import { finite, infinite } from './position.mjs'; - -/** Represents an item that's part of a layout, about to be rendered */ -export type Layout = { - item: T; - index: number; - active: boolean; -}; +import { type Layout } from './layout.type.mjs'; export type Options = { items: readonly T[]; From 291c094862b6c00dcb97dd542334f273536a79e1 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 09:30:17 +0100 Subject: [PATCH 63/94] Export layout type --- packages/core/src/lib/pagination/index.mts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/lib/pagination/index.mts b/packages/core/src/lib/pagination/index.mts index 00411ecc5..0fb0bc26c 100644 --- a/packages/core/src/lib/pagination/index.mts +++ b/packages/core/src/lib/pagination/index.mts @@ -1 +1,2 @@ +export * from './layout.type.mjs'; export * from './use-pagination.mjs'; From 7a5c54ba796c61ab459c8fd1d9d319b41acb2ac5 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 09:50:41 +0100 Subject: [PATCH 64/94] Remove active state management from pagination --- packages/checkbox/src/index.mts | 15 +++++++-------- .../core/src/lib/pagination/use-pagination.mts | 17 +++++------------ packages/select/src/index.mts | 12 +++++++----- 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 164f0eec5..3c5ebd3c0 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -72,25 +72,24 @@ export default createPrompt( done: (value: Array) => void, ): string => { const { prefix = usePrefix(), instructions, pageSize, loop, choices } = config; - const firstRender = useRef(true); const [status, setStatus] = useState('pending'); const [items, setItems] = useState>>( choices.map((choice) => ({ ...choice })), ); + const [active, setActive] = useState(() => { + const selected = items.findIndex(selectable); + if (selected < 0) throw new Error(`[checkbox prompt] Nothing selectable`); + return selected; + }); const [showHelpTip, setShowHelpTip] = useState(true); const message = chalk.bold(config.message); - const { contents, active, setActive } = usePagination>({ + const contents = usePagination>({ items, + active, render, pageSize, loop, }); - if (firstRender.current) { - firstRender.current = false; - const selected = items.findIndex(selectable); - if (selected < 0) throw new Error(`[checkbox prompt] Nothing selectable`); - setActive(selected); - } useSpeedDial({ items, selectable, setActive }); useScroll({ items, selectable, active, setActive, loop }); diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index 0f9755aab..4e92f6567 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -2,13 +2,14 @@ import chalk from 'chalk'; import cliWidth from 'cli-width'; import { readline } from '../hook-engine.mjs'; import { useRef } from '../use-ref.mjs'; -import { useState } from '../use-state.mjs'; import { lines } from './lines.mjs'; import { finite, infinite } from './position.mjs'; import { type Layout } from './layout.type.mjs'; export type Options = { items: readonly T[]; + /** The index of the active item. */ + active: number; /** Renders an item as part of a page. */ render: (layout: Layout) => string; /** The size of the page. `7` if unspecified. */ @@ -17,26 +18,19 @@ export type Options = { loop?: boolean; }; -export type Page = { - contents: string; - active: number; - /** Sets the index of the active item. */ - setActive: (active: number) => void; -}; - export const usePagination = ({ items, + active, render, pageSize = 7, loop = true, -}: Options): Page => { +}: Options): string => { const rl = readline(); const width = cliWidth({ defaultWidth: 80, output: rl.output }); const state = useRef({ position: 0, lastActive: 0, }); - const [active, setActive] = useState(0); const position = (loop ? infinite : finite)( { active: { current: active, previous: state.current.lastActive }, @@ -48,12 +42,11 @@ export const usePagination = ({ state.current.position = position; state.current.lastActive = active; - const contents = lines({ items, width, render, active, position, pageSize }) + return lines({ items, width, render, active, position, pageSize }) .concat( items.length <= pageSize ? [] : [chalk.dim('(Use arrow keys to reveal more choices)')], ) .join('\n'); - return { contents, active, setActive }; }; diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 21be8fba3..b3c7a9829 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -60,11 +60,16 @@ export default createPrompt( ): string => { const { choices: items, loop, pageSize } = config; const firstRender = useRef(true); - const prefix = usePrefix(); const [status, setStatus] = useState('pending'); - const { contents, active, setActive } = usePagination>({ + const [active, setActive] = useState(() => { + const selected = items.findIndex(selectable); + if (selected < 0) throw new Error('[select prompt] No selectable choices.'); + return selected; + }); + const contents = usePagination>({ items, + active, render, pageSize, loop, @@ -85,9 +90,6 @@ export default createPrompt( if (firstRender.current) { firstRender.current = false; message += chalk.dim(' (Use arrow keys)'); - const selected = items.findIndex(selectable); - if (selected < 0) throw new Error('[select prompt] No selectable choices.'); - setActive(selected); } if (status === 'done') { From 4b4c1384de92a4cbc6d8789c490126e714d81bef Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 09:51:19 +0100 Subject: [PATCH 65/94] Export choice types --- packages/checkbox/src/index.mts | 2 +- packages/select/src/index.mts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 3c5ebd3c0..453743d11 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -18,7 +18,7 @@ import chalk from 'chalk'; import ansiEscapes from 'ansi-escapes'; import figures from 'figures'; -type Choice = { +export type Choice = { name?: string; value: Value; disabled?: boolean | string; diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index b3c7a9829..87d65f9ad 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -17,7 +17,7 @@ import chalk from 'chalk'; import ansiEscapes from 'ansi-escapes'; import figures from 'figures'; -type Choice = { +export type Choice = { value: Value; name?: string; description?: string; From 618ffc973e2b6b91ca81e5b4709a7cefe73ed532 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 09:58:16 +0100 Subject: [PATCH 66/94] Remove use-scroll --- packages/checkbox/src/index.mts | 19 ++++++++-- packages/core/src/index.mts | 1 + packages/core/src/lib/navigation/index.mts | 1 - .../core/src/lib/navigation/use-scroll.mts | 38 ------------------- packages/select/src/index.mts | 19 ++++++++-- 5 files changed, 32 insertions(+), 46 deletions(-) delete mode 100644 packages/core/src/lib/navigation/use-scroll.mts diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 453743d11..d7941b462 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -4,14 +4,15 @@ import { useKeypress, usePrefix, usePagination, - useScroll, useSpeedDial, isSpaceKey, isNumberKey, isEnterKey, type Layout, Separator, - useRef, + isUpKey, + isDownKey, + index, } from '@inquirer/core'; import type {} from '@inquirer/type'; import chalk from 'chalk'; @@ -71,7 +72,7 @@ export default createPrompt( config: Config, done: (value: Array) => void, ): string => { - const { prefix = usePrefix(), instructions, pageSize, loop, choices } = config; + const { prefix = usePrefix(), instructions, pageSize, loop = true, choices } = config; const [status, setStatus] = useState('pending'); const [items, setItems] = useState>>( choices.map((choice) => ({ ...choice })), @@ -91,9 +92,19 @@ export default createPrompt( loop, }); useSpeedDial({ items, selectable, setActive }); - useScroll({ items, selectable, active, setActive, loop }); useKeypress((key) => { + if (!loop && active === 0 && isUpKey(key)) return; + if (!loop && active === items.length - 1 && isDownKey(key)) return; + if (isUpKey(key) || isDownKey(key)) { + const offset = isUpKey(key) ? -1 : 1; + let next = active; + do { + next = index(items.length, next + offset); + } while (!selectable(items[next]!)); + setActive(next); + return; + } if (isEnterKey(key)) { setStatus('done'); done( diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index 17d9adae0..daa949268 100644 --- a/packages/core/src/index.mts +++ b/packages/core/src/index.mts @@ -9,3 +9,4 @@ export * from './lib/pagination/index.mjs'; export { createPrompt, type AsyncPromptConfig } from './lib/create-prompt.mjs'; export { Separator } from './lib/Separator.mjs'; export { type InquirerReadline } from './lib/read-line.type.mjs'; +export { index } from './lib/utils.mjs'; diff --git a/packages/core/src/lib/navigation/index.mts b/packages/core/src/lib/navigation/index.mts index 307387166..c64dcf957 100644 --- a/packages/core/src/lib/navigation/index.mts +++ b/packages/core/src/lib/navigation/index.mts @@ -1,2 +1 @@ export * from './use-speed-dial.mjs'; -export * from './use-scroll.mjs'; diff --git a/packages/core/src/lib/navigation/use-scroll.mts b/packages/core/src/lib/navigation/use-scroll.mts deleted file mode 100644 index 981fe1f4f..000000000 --- a/packages/core/src/lib/navigation/use-scroll.mts +++ /dev/null @@ -1,38 +0,0 @@ -import { isUpKey, isDownKey } from '../key.mjs'; -import { useKeypress } from '../use-keypress.mjs'; -import { index } from '../utils.mjs'; - -type ScrollOptions = { - items: readonly T[]; - active: number; - /** Sets the index of the active item. */ - setActive: (active: number) => void; - /** Returns whether an item can be selected. */ - selectable: (item: T) => boolean; - /** Allows wrapping on either sides of the list on navigation. True by default. */ - loop?: boolean; -}; - -/** - * Allows scrolling through a list of items with an active cursor - */ -export const useScroll = ({ - items, - selectable, - active, - setActive, - loop = true, -}: ScrollOptions) => { - useKeypress((key) => { - if (!loop && active === 0 && isUpKey(key)) return; - if (!loop && active === items.length - 1 && isDownKey(key)) return; - if (isUpKey(key) || isDownKey(key)) { - const offset = isUpKey(key) ? -1 : 1; - let next = active; - do { - next = index(items.length, next + offset); - } while (!selectable(items[next]!)); - setActive(next); - } - }); -}; diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 87d65f9ad..172005d8c 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -4,13 +4,15 @@ import { useKeypress, usePrefix, usePagination, - useScroll, useSpeedDial, useRef, isEnterKey, type Layout, Separator, AsyncPromptConfig, + isUpKey, + isDownKey, + index, } from '@inquirer/core'; import type {} from '@inquirer/type'; import chalk from 'chalk'; @@ -58,7 +60,7 @@ export default createPrompt( config: SelectConfig, done: (value: Value) => void, ): string => { - const { choices: items, loop, pageSize } = config; + const { choices: items, loop = true, pageSize } = config; const firstRender = useRef(true); const prefix = usePrefix(); const [status, setStatus] = useState('pending'); @@ -75,11 +77,22 @@ export default createPrompt( loop, }); useSpeedDial({ items, selectable, setActive }); - useScroll({ items, selectable, active, setActive, loop }); + // Safe to assume the cursor position always point to a Choice. const selectedChoice = items[active] as Choice; useKeypress((key) => { + if (!loop && active === 0 && isUpKey(key)) return; + if (!loop && active === items.length - 1 && isDownKey(key)) return; + if (isUpKey(key) || isDownKey(key)) { + const offset = isUpKey(key) ? -1 : 1; + let next = active; + do { + next = index(items.length, next + offset); + } while (!selectable(items[next]!)); + setActive(next); + return; + } if (isEnterKey(key)) { setStatus('done'); done(selectedChoice.value); From 6c38df0f0f18462fb568b94db69cbe3c11e9afd8 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 10:04:21 +0100 Subject: [PATCH 67/94] Remove use-speed-dial --- packages/checkbox/src/index.mts | 10 +++---- packages/core/src/index.mts | 1 - packages/core/src/lib/navigation/index.mts | 1 - .../src/lib/navigation/use-speed-dial.mts | 26 ------------------- packages/select/src/index.mts | 12 +++++---- 5 files changed, 11 insertions(+), 39 deletions(-) delete mode 100644 packages/core/src/lib/navigation/index.mts delete mode 100644 packages/core/src/lib/navigation/use-speed-dial.mts diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index d7941b462..b5bb50bfb 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -4,7 +4,6 @@ import { useKeypress, usePrefix, usePagination, - useSpeedDial, isSpaceKey, isNumberKey, isEnterKey, @@ -91,7 +90,6 @@ export default createPrompt( pageSize, loop, }); - useSpeedDial({ items, selectable, setActive }); useKeypress((key) => { if (!loop && active === 0 && isUpKey(key)) return; @@ -103,9 +101,7 @@ export default createPrompt( next = index(items.length, next + offset); } while (!selectable(items[next]!)); setActive(next); - return; - } - if (isEnterKey(key)) { + } else if (isEnterKey(key)) { setStatus('done'); done( items @@ -125,7 +121,9 @@ export default createPrompt( } else if (isNumberKey(key)) { // Adjust index to start at 1 const position = Number(key.name) - 1; - // Toggle when speed dialled + const item = items[position]; + if (item == null || !selectable(item)) return; + setActive(position); setItems(items.map((choice, i) => (i === position ? toggle(choice) : choice))); } }); diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index daa949268..1c58a9707 100644 --- a/packages/core/src/index.mts +++ b/packages/core/src/index.mts @@ -4,7 +4,6 @@ export { useState } from './lib/use-state.mjs'; export { useEffect } from './lib/use-effect.mjs'; export { useRef } from './lib/use-ref.mjs'; export { useKeypress } from './lib/use-keypress.mjs'; -export * from './lib/navigation/index.mjs'; export * from './lib/pagination/index.mjs'; export { createPrompt, type AsyncPromptConfig } from './lib/create-prompt.mjs'; export { Separator } from './lib/Separator.mjs'; diff --git a/packages/core/src/lib/navigation/index.mts b/packages/core/src/lib/navigation/index.mts deleted file mode 100644 index c64dcf957..000000000 --- a/packages/core/src/lib/navigation/index.mts +++ /dev/null @@ -1 +0,0 @@ -export * from './use-speed-dial.mjs'; diff --git a/packages/core/src/lib/navigation/use-speed-dial.mts b/packages/core/src/lib/navigation/use-speed-dial.mts deleted file mode 100644 index b64960ed6..000000000 --- a/packages/core/src/lib/navigation/use-speed-dial.mts +++ /dev/null @@ -1,26 +0,0 @@ -import { isNumberKey } from '../key.mjs'; -import { useKeypress } from '../use-keypress.mjs'; - -type SpeedDialOptions = { - items: readonly T[]; - /** Sets the index of the active item. */ - setActive: (active: number) => void; - /** Returns whether an item can be selected. */ - selectable: (item: T) => boolean; -}; - -/** - * Allows quickly selecting items from 1-9 by pressing a number key. - */ -export const useSpeedDial = ({ - items, - selectable, - setActive, -}: SpeedDialOptions) => { - useKeypress((key) => { - if (!isNumberKey(key)) return; - const index = Number(key.name) - 1; - if (items[index] == null || !selectable(items[index]!)) return; - setActive(index); - }); -}; diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 172005d8c..e6ef0922a 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -4,7 +4,6 @@ import { useKeypress, usePrefix, usePagination, - useSpeedDial, useRef, isEnterKey, type Layout, @@ -13,6 +12,7 @@ import { isUpKey, isDownKey, index, + isNumberKey, } from '@inquirer/core'; import type {} from '@inquirer/type'; import chalk from 'chalk'; @@ -76,7 +76,6 @@ export default createPrompt( pageSize, loop, }); - useSpeedDial({ items, selectable, setActive }); // Safe to assume the cursor position always point to a Choice. const selectedChoice = items[active] as Choice; @@ -91,9 +90,12 @@ export default createPrompt( next = index(items.length, next + offset); } while (!selectable(items[next]!)); setActive(next); - return; - } - if (isEnterKey(key)) { + } else if (isNumberKey(key)) { + const position = Number(key.name) - 1; + const item = items[position]; + if (item == null || !selectable(item)) return; + setActive(position); + } else if (isEnterKey(key)) { setStatus('done'); done(selectedChoice.value); } From ac3ca3eec959b396eb6e727be815b22a3baaf00d Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 10:09:37 +0100 Subject: [PATCH 68/94] Reorder imports --- packages/checkbox/src/index.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index b5bb50bfb..0b4aafa65 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -15,8 +15,8 @@ import { } from '@inquirer/core'; import type {} from '@inquirer/type'; import chalk from 'chalk'; -import ansiEscapes from 'ansi-escapes'; import figures from 'figures'; +import ansiEscapes from 'ansi-escapes'; export type Choice = { name?: string; From 5834d89b5931347c4742d7debf8137b80a861c7d Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 10:09:49 +0100 Subject: [PATCH 69/94] Reorder cases --- packages/select/src/index.mts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index e6ef0922a..990c6ac48 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -83,7 +83,10 @@ export default createPrompt( useKeypress((key) => { if (!loop && active === 0 && isUpKey(key)) return; if (!loop && active === items.length - 1 && isDownKey(key)) return; - if (isUpKey(key) || isDownKey(key)) { + if (isEnterKey(key)) { + setStatus('done'); + done(selectedChoice.value); + } else if (isUpKey(key) || isDownKey(key)) { const offset = isUpKey(key) ? -1 : 1; let next = active; do { @@ -95,9 +98,6 @@ export default createPrompt( const item = items[position]; if (item == null || !selectable(item)) return; setActive(position); - } else if (isEnterKey(key)) { - setStatus('done'); - done(selectedChoice.value); } }); From 8f761694b8fe1fc1b9af61115397a51411b740d5 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 10:10:56 +0100 Subject: [PATCH 70/94] Reorder cases --- packages/checkbox/src/index.mts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 0b4aafa65..8fe22c66c 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -94,20 +94,20 @@ export default createPrompt( useKeypress((key) => { if (!loop && active === 0 && isUpKey(key)) return; if (!loop && active === items.length - 1 && isDownKey(key)) return; - if (isUpKey(key) || isDownKey(key)) { - const offset = isUpKey(key) ? -1 : 1; - let next = active; - do { - next = index(items.length, next + offset); - } while (!selectable(items[next]!)); - setActive(next); - } else if (isEnterKey(key)) { + if (isEnterKey(key)) { setStatus('done'); done( items .filter((choice) => selectable(choice) && choice.checked) .map((choice) => (choice as Choice).value), ); + } else if (isUpKey(key) || isDownKey(key)) { + const offset = isUpKey(key) ? -1 : 1; + let next = active; + do { + next = index(items.length, next + offset); + } while (!selectable(items[next]!)); + setActive(next); } else if (isSpaceKey(key)) { setShowHelpTip(false); setItems(items.map((choice, i) => (i === active ? toggle(choice) : choice))); From 0db9779284eba2860e4f4168c368af3f130e143d Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 10:13:54 +0100 Subject: [PATCH 71/94] Move conditions --- packages/checkbox/src/index.mts | 4 ++-- packages/select/src/index.mts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 8fe22c66c..c763e2664 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -92,8 +92,6 @@ export default createPrompt( }); useKeypress((key) => { - if (!loop && active === 0 && isUpKey(key)) return; - if (!loop && active === items.length - 1 && isDownKey(key)) return; if (isEnterKey(key)) { setStatus('done'); done( @@ -102,6 +100,8 @@ export default createPrompt( .map((choice) => (choice as Choice).value), ); } else if (isUpKey(key) || isDownKey(key)) { + if (!loop && active === 0 && isUpKey(key)) return; + if (!loop && active === items.length - 1 && isDownKey(key)) return; const offset = isUpKey(key) ? -1 : 1; let next = active; do { diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 990c6ac48..2c0dbb235 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -81,12 +81,12 @@ export default createPrompt( const selectedChoice = items[active] as Choice; useKeypress((key) => { - if (!loop && active === 0 && isUpKey(key)) return; - if (!loop && active === items.length - 1 && isDownKey(key)) return; if (isEnterKey(key)) { setStatus('done'); done(selectedChoice.value); } else if (isUpKey(key) || isDownKey(key)) { + if (!loop && active === 0 && isUpKey(key)) return; + if (!loop && active === items.length - 1 && isDownKey(key)) return; const offset = isUpKey(key) ? -1 : 1; let next = active; do { From c0cd080a27dd5c3b6e3ce31e1e2f35b2eea75fbe Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 10:15:19 +0100 Subject: [PATCH 72/94] Reorder imports --- packages/checkbox/src/index.mts | 6 +++--- packages/select/src/index.mts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index c763e2664..a4be157e4 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -4,13 +4,13 @@ import { useKeypress, usePrefix, usePagination, + isUpKey, + isDownKey, isSpaceKey, isNumberKey, isEnterKey, - type Layout, Separator, - isUpKey, - isDownKey, + type Layout, index, } from '@inquirer/core'; import type {} from '@inquirer/type'; diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 2c0dbb235..3e5427252 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -6,18 +6,18 @@ import { usePagination, useRef, isEnterKey, - type Layout, - Separator, - AsyncPromptConfig, isUpKey, isDownKey, - index, isNumberKey, + Separator, + AsyncPromptConfig, + type Layout, + index, } from '@inquirer/core'; import type {} from '@inquirer/type'; import chalk from 'chalk'; -import ansiEscapes from 'ansi-escapes'; import figures from 'figures'; +import ansiEscapes from 'ansi-escapes'; export type Choice = { value: Value; From c75a1ed46ab73ff07c64149a1f166f23880388ae Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 9 Sep 2023 10:29:03 +0100 Subject: [PATCH 73/94] Move pagination --- packages/checkbox/src/index.mts | 17 +++++++++-------- packages/select/src/index.mts | 17 +++++++++-------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index a4be157e4..59ebf342a 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -83,13 +83,6 @@ export default createPrompt( }); const [showHelpTip, setShowHelpTip] = useState(true); const message = chalk.bold(config.message); - const contents = usePagination>({ - items, - active, - render, - pageSize, - loop, - }); useKeypress((key) => { if (isEnterKey(key)) { @@ -128,6 +121,14 @@ export default createPrompt( } }); + const page = usePagination>({ + items, + active, + render, + pageSize, + loop, + }); + if (status === 'done') { const selection = items .filter((choice) => selectable(choice) && choice.checked) @@ -152,7 +153,7 @@ export default createPrompt( } } - return `${prefix} ${message}${helpTip}\n${contents}${ansiEscapes.cursorHide}`; + return `${prefix} ${message}${helpTip}\n${page}${ansiEscapes.cursorHide}`; }, ); diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 3e5427252..475b93813 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -69,13 +69,6 @@ export default createPrompt( if (selected < 0) throw new Error('[select prompt] No selectable choices.'); return selected; }); - const contents = usePagination>({ - items, - active, - render, - pageSize, - loop, - }); // Safe to assume the cursor position always point to a Choice. const selectedChoice = items[active] as Choice; @@ -107,6 +100,14 @@ export default createPrompt( message += chalk.dim(' (Use arrow keys)'); } + const page = usePagination>({ + items, + active, + render, + pageSize, + loop, + }); + if (status === 'done') { return `${prefix} ${message} ${chalk.cyan( selectedChoice.name || selectedChoice.value, @@ -117,7 +118,7 @@ export default createPrompt( ? `\n${selectedChoice.description}` : ``; - return `${prefix} ${message}\n${contents}${choiceDescription}${ansiEscapes.cursorHide}`; + return `${prefix} ${message}\n${page}${choiceDescription}${ansiEscapes.cursorHide}`; }, ); From 15153b321b39a7a91f1195e30461c3a38529b4d7 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sun, 10 Sep 2023 11:09:40 +0100 Subject: [PATCH 74/94] Inline render type --- packages/checkbox/src/index.mts | 3 +-- packages/select/src/index.mts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index aa367fc1d..edabf9940 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -10,7 +10,6 @@ import { isNumberKey, isEnterKey, Separator, - type Layout, index, type PromptConfig, } from '@inquirer/core'; @@ -40,7 +39,7 @@ const check = const toggle = (item: Item): Item => selectable(item) ? { ...item, checked: !item.checked } : item; -const render = ({ item, active }: Layout>) => { +const render = ({ item, active }: { item: Item; active: boolean }) => { if (Separator.isSeparator(item)) { return ` ${item.separator}`; } diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 1b8a5399b..766e37e67 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -10,7 +10,6 @@ import { isDownKey, isNumberKey, Separator, - type Layout, index, type PromptConfig, } from '@inquirer/core'; @@ -32,7 +31,7 @@ type Item = Separator | Choice; const selectable = (item: Item): item is Choice => !Separator.isSeparator(item) && !item.disabled; -const render = ({ item, active }: Layout>) => { +const render = ({ item, active }: { item: Item; active: boolean }) => { if (Separator.isSeparator(item)) { return ` ${item.separator}`; } From f74b780a82b836256ac364aa38cfb91d7fa270d6 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sun, 10 Sep 2023 11:10:04 +0100 Subject: [PATCH 75/94] Stop exporting layout type --- packages/core/src/lib/pagination/index.mts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/lib/pagination/index.mts b/packages/core/src/lib/pagination/index.mts index 0fb0bc26c..00411ecc5 100644 --- a/packages/core/src/lib/pagination/index.mts +++ b/packages/core/src/lib/pagination/index.mts @@ -1,2 +1 @@ -export * from './layout.type.mjs'; export * from './use-pagination.mjs'; From 6017d600e82085884f45338f4cda684e17f8c5f9 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sun, 10 Sep 2023 11:11:47 +0100 Subject: [PATCH 76/94] Inline index calculation --- packages/checkbox/src/index.mts | 3 +-- packages/select/src/index.mts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index edabf9940..0e32bfd13 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -10,7 +10,6 @@ import { isNumberKey, isEnterKey, Separator, - index, type PromptConfig, } from '@inquirer/core'; import type {} from '@inquirer/type'; @@ -94,7 +93,7 @@ export default createPrompt( const offset = isUpKey(key) ? -1 : 1; let next = active; do { - next = index(items.length, next + offset); + next = (((next + offset) % items.length) + items.length) % items.length; } while (!selectable(items[next]!)); setActive(next); } else if (isSpaceKey(key)) { diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 766e37e67..f1f8f2373 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -10,7 +10,6 @@ import { isDownKey, isNumberKey, Separator, - index, type PromptConfig, } from '@inquirer/core'; import type {} from '@inquirer/type'; @@ -82,7 +81,7 @@ export default createPrompt( const offset = isUpKey(key) ? -1 : 1; let next = active; do { - next = index(items.length, next + offset); + next = (((next + offset) % items.length) + items.length) % items.length; } while (!selectable(items[next]!)); setActive(next); } else if (isNumberKey(key)) { From 2b3a8d06888e7be43cec64c80ac4692423eba982 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sun, 10 Sep 2023 11:12:04 +0100 Subject: [PATCH 77/94] Stop exporting index --- packages/core/src/lib/utils.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/lib/utils.mts b/packages/core/src/lib/utils.mts index 3b7dffe94..6ad5f17d1 100644 --- a/packages/core/src/lib/utils.mts +++ b/packages/core/src/lib/utils.mts @@ -22,7 +22,7 @@ export const breakLines = (content: string, width: number): string => * @param {number} max The maximum count * @param {number} value The value to convert to index */ -export const index = (max: number, value: number) => ((value % max) + max) % max; +const index = (max: number, value: number) => ((value % max) + max) % max; /** * Rotates an array of items by an integer number of positions. From 3639f5cfa93333c24b59f20d5cdb404bdca5d371 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Mon, 11 Sep 2023 00:32:57 +0100 Subject: [PATCH 78/94] Stop exporting index --- packages/core/src/index.mts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index c507fe4e1..fd1efbc04 100644 --- a/packages/core/src/index.mts +++ b/packages/core/src/index.mts @@ -12,4 +12,3 @@ export { } from './lib/create-prompt.mjs'; export { Separator } from './lib/Separator.mjs'; export { type InquirerReadline } from './lib/read-line.type.mjs'; -export { index } from './lib/utils.mjs'; From 9f6b63222bd93c97577fae7acb351436e5b72b09 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Mon, 11 Sep 2023 00:39:58 +0100 Subject: [PATCH 79/94] Exclude test files from compilation --- packages/core/tsconfig.cjs.json | 1 - packages/core/tsconfig.json | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tsconfig.cjs.json b/packages/core/tsconfig.cjs.json index 3c4925259..3427c0353 100644 --- a/packages/core/tsconfig.cjs.json +++ b/packages/core/tsconfig.cjs.json @@ -1,6 +1,5 @@ { "extends": "./tsconfig.json", - "include": ["./src"], "compilerOptions": { "lib": ["ES6"], "target": "es6", diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 8cf88fecb..43ecff2ab 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "include": ["./src"], + "exclude": ["./src/**/*.test.mts"], "compilerOptions": { "lib": ["ESNext"], "target": "es2022", From 14ac65ffa930c08f1ba6032df3391a92b3b48642 Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Sat, 16 Sep 2023 04:49:04 -0700 Subject: [PATCH 80/94] Update packages/select/src/index.mts --- packages/select/src/index.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 3afd723a8..b6a58dab0 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -17,7 +17,7 @@ import chalk from 'chalk'; import figures from 'figures'; import ansiEscapes from 'ansi-escapes'; -export type Choice = { +type Choice = { value: Value; name?: string; description?: string; From 40c2a97e03e963a535992476d78f281411cfa82b Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Sat, 16 Sep 2023 04:59:14 -0700 Subject: [PATCH 81/94] Update packages/core/src/index.mts --- packages/core/src/index.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index fd1efbc04..407e5d5cb 100644 --- a/packages/core/src/index.mts +++ b/packages/core/src/index.mts @@ -4,7 +4,7 @@ export { useState } from './lib/use-state.mjs'; export { useEffect } from './lib/use-effect.mjs'; export { useRef } from './lib/use-ref.mjs'; export { useKeypress } from './lib/use-keypress.mjs'; -export * from './lib/pagination/index.mjs'; +export { usePagination } from './lib/pagination/index.mjs'; export { createPrompt, type PromptConfig, From 04af3d656b8e990352540740469d932c92f2b3d8 Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Sat, 16 Sep 2023 08:12:11 -0400 Subject: [PATCH 82/94] Nits --- packages/checkbox/src/index.mts | 2 +- packages/core/src/lib/pagination/lines.mts | 14 ++++----- .../core/src/lib/pagination/lines.test.mts | 29 ++++++++++++------- .../src/lib/pagination/use-pagination.mts | 16 +++++----- packages/select/src/index.mts | 2 +- 5 files changed, 35 insertions(+), 28 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index c19c12ba2..b8966efcb 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -126,7 +126,7 @@ export default createPrompt( const page = usePagination>({ items, active, - render: renderItem, + renderItem, pageSize, loop, }); diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 13906302b..9f0c7c724 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -1,12 +1,12 @@ import { breakLines, rotate } from '../utils.mjs'; import { type Layout } from './layout.type.mjs'; -type Inputs = { +type Options = { items: readonly T[]; /** The width of a rendered line in characters. */ width: number; /** Renders an item as part of a page. */ - render: (layout: Layout) => string; + renderItem: (layout: Layout) => 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. */ @@ -22,14 +22,14 @@ type Inputs = { * of the active item get rendered as possible. * @returns The rendered lines */ -export const lines = ({ +export function lines({ items, width, - render, + renderItem, active, position: requested, pageSize, -}: Inputs): string[] => { +}: Options): string[] { const split = (content: string) => breakLines(content, width).split('\n'); const layouts = items.map>((item, index) => ({ item, @@ -37,7 +37,7 @@ export const lines = ({ isActive: index === active, })); const layoutsInPage = rotate(active - requested, layouts).slice(0, pageSize); - const getLines = (index: number) => split(render(layoutsInPage[index]!)); + const getLines = (index: number) => split(renderItem(layoutsInPage[index]!)); // Create a blank array of lines for the page const page = new Array(pageSize); @@ -77,4 +77,4 @@ export const lines = ({ } return page.filter((line) => typeof line === 'string'); -}; +} diff --git a/packages/core/src/lib/pagination/lines.test.mts b/packages/core/src/lib/pagination/lines.test.mts index 73a5f8de6..d9c834774 100644 --- a/packages/core/src/lib/pagination/lines.test.mts +++ b/packages/core/src/lib/pagination/lines.test.mts @@ -1,24 +1,31 @@ import { describe, it, expect } from 'vitest'; import { lines } from './lines.mjs'; -import { type Layout } from './layout.type.mjs'; describe('lines(...)', () => { const items = [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }]; const renderLines = (count: number) => - ({ index, item: { value }, active: cursor }: Layout<{ value: number }>): string => + ({ + index, + item: { value }, + isActive, + }: { + item: { value: number }; + isActive: boolean; + index: number; + }): string => new Array(count) .fill(0) .map((_, i) => { - const pointer = cursor ? '>' : ' '; + const pointer = isActive ? '>' : ' '; const prefix = i === 0 ? `${pointer} ${(index + 1).toString()}.` : ' '; return `${prefix}${value} line ${i + 1}`; }) .join('\n'); describe('given the active item can be rendered completely at given position', () => { - const render = renderLines(3); + const renderItem = renderLines(3); it('should return expected pointer', () => { const result = lines({ @@ -26,7 +33,7 @@ describe('lines(...)', () => { active: 2, pageSize: 5, position: 2, - render, + renderItem, width: 20, }); expect(result).toEqual([ @@ -40,7 +47,7 @@ describe('lines(...)', () => { }); describe('given the active item can be rendered completely only at earlier position', () => { - const render = renderLines(4); + const renderItem = renderLines(4); it('should return expected pointer', () => { const result = lines({ @@ -48,7 +55,7 @@ describe('lines(...)', () => { active: 2, pageSize: 5, position: 2, - render, + renderItem, width: 20, }); expect(result).toEqual([ @@ -62,7 +69,7 @@ describe('lines(...)', () => { }); describe('given the active item can be rendered completely only at top', () => { - const render = renderLines(5); + const renderItem = renderLines(5); it('should return expected pointer', () => { const result = lines({ @@ -70,7 +77,7 @@ describe('lines(...)', () => { active: 2, pageSize: 5, position: 2, - render, + renderItem, width: 20, }); expect(result).toEqual([ @@ -84,7 +91,7 @@ describe('lines(...)', () => { }); describe('given the active item cannot be rendered completely at any position', () => { - const render = renderLines(6); + const renderItem = renderLines(6); it('should return expected pointer', () => { const result = lines({ @@ -92,7 +99,7 @@ describe('lines(...)', () => { active: 2, pageSize: 5, position: 2, - render, + renderItem, width: 20, }); expect(result).toEqual([ diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index 4e92f6567..91cae0bfd 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -6,25 +6,25 @@ import { lines } from './lines.mjs'; import { finite, infinite } from './position.mjs'; import { type Layout } from './layout.type.mjs'; -export type Options = { +type Options = { items: readonly T[]; /** The index of the active item. */ active: number; - /** Renders an item as part of a page. */ - render: (layout: Layout) => string; + /** RenderItems an item as part of a page. */ + renderItem: (layout: Layout) => string; /** The size of the page. `7` if unspecified. */ pageSize?: number; /** Allows creating an infinitely looping list. `true` if unspecified. */ loop?: boolean; }; -export const usePagination = ({ +export function usePagination({ items, active, - render, + renderItem, pageSize = 7, loop = true, -}: Options): string => { +}: Options): string { const rl = readline(); const width = cliWidth({ defaultWidth: 80, output: rl.output }); const state = useRef({ @@ -42,11 +42,11 @@ export const usePagination = ({ state.current.position = position; state.current.lastActive = active; - return lines({ items, width, render, active, position, pageSize }) + return lines({ items, width, renderItem, active, position, pageSize }) .concat( items.length <= pageSize ? [] : [chalk.dim('(Use arrow keys to reveal more choices)')], ) .join('\n'); -}; +} diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 125b380ae..f565760cd 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -105,7 +105,7 @@ export default createPrompt( const page = usePagination>({ items, active, - render: renderItem, + renderItem, pageSize, loop, }); From ace3a2fa3aa8a03568eea82b3f1c356c49757c1a Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 16 Sep 2023 14:45:16 +0100 Subject: [PATCH 83/94] Fix doc --- packages/core/src/lib/pagination/use-pagination.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index 91cae0bfd..8e8a91650 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -10,7 +10,7 @@ type Options = { items: readonly T[]; /** The index of the active item. */ active: number; - /** RenderItems an item as part of a page. */ + /** Renders an item as part of a page. */ renderItem: (layout: Layout) => string; /** The size of the page. `7` if unspecified. */ pageSize?: number; From a6a46436cc09c4b28933a0ad17ded9509b344bbf Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 16 Sep 2023 14:50:59 +0100 Subject: [PATCH 84/94] Prettify layout type for erasure --- packages/core/src/lib/pagination/lines.mts | 3 ++- packages/core/src/lib/pagination/use-pagination.mts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index 9f0c7c724..c61eec817 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -1,3 +1,4 @@ +import { type Prettify } from '@inquirer/type'; import { breakLines, rotate } from '../utils.mjs'; import { type Layout } from './layout.type.mjs'; @@ -6,7 +7,7 @@ type Options = { /** The width of a rendered line in characters. */ width: number; /** Renders an item as part of a page. */ - renderItem: (layout: Layout) => string; + renderItem: (layout: Prettify>) => 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. */ diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index 8e8a91650..95091adcd 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import cliWidth from 'cli-width'; +import { type Prettify } from '@inquirer/type'; import { readline } from '../hook-engine.mjs'; import { useRef } from '../use-ref.mjs'; import { lines } from './lines.mjs'; @@ -11,7 +12,7 @@ type Options = { /** The index of the active item. */ active: number; /** Renders an item as part of a page. */ - renderItem: (layout: Layout) => string; + renderItem: (layout: Prettify>) => string; /** The size of the page. `7` if unspecified. */ pageSize?: number; /** Allows creating an infinitely looping list. `true` if unspecified. */ From 52fdc3c884705dc3cff53cabfee7cf242c618b10 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 16 Sep 2023 15:01:12 +0100 Subject: [PATCH 85/94] Rewrite docs --- packages/core/README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index b0df50a46..d3ca4021c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -126,19 +126,23 @@ Listening for keypress events inside an inquirer prompt is a very common pattern 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. +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}`; }); ``` From 7e1c9a38b338565fd73bbc62e1bfa3e115c88cfc Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Sat, 16 Sep 2023 15:01:58 +0100 Subject: [PATCH 86/94] Fix typo --- packages/core/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/README.md b/packages/core/README.md index d3ca4021c..7aa2b6e28 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -124,7 +124,7 @@ 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`. From bd7821492be19d6f90a4e180391ceb18c2009b3e Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Sun, 24 Sep 2023 16:19:37 -0400 Subject: [PATCH 87/94] Remove unecessary file --- packages/core/src/index.mts | 2 +- packages/core/src/lib/pagination/index.mts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 packages/core/src/lib/pagination/index.mts diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index 407e5d5cb..ccce68e28 100644 --- a/packages/core/src/index.mts +++ b/packages/core/src/index.mts @@ -4,7 +4,7 @@ export { useState } from './lib/use-state.mjs'; export { useEffect } from './lib/use-effect.mjs'; export { useRef } from './lib/use-ref.mjs'; export { useKeypress } from './lib/use-keypress.mjs'; -export { usePagination } from './lib/pagination/index.mjs'; +export { usePagination } from './lib/pagination/use-pagination.mjs'; export { createPrompt, type PromptConfig, diff --git a/packages/core/src/lib/pagination/index.mts b/packages/core/src/lib/pagination/index.mts deleted file mode 100644 index 00411ecc5..000000000 --- a/packages/core/src/lib/pagination/index.mts +++ /dev/null @@ -1 +0,0 @@ -export * from './use-pagination.mjs'; From f1930215de2b9d8e16fd9cb66a8788aaaf891808 Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Sun, 24 Sep 2023 17:27:34 -0400 Subject: [PATCH 88/94] Inlining, renames and 'dumbify' the code --- .../core/src/lib/pagination/layout.type.mts | 6 - packages/core/src/lib/pagination/lines.mts | 101 +++++++++-------- .../core/src/lib/pagination/lines.test.mts | 105 ++++++++++++------ packages/core/src/lib/pagination/position.mts | 63 ++++++----- .../src/lib/pagination/use-pagination.mts | 73 ++++++------ packages/core/src/lib/utils.mts | 26 ++--- packages/demo/demos/select.mjs | 66 ++++++----- 7 files changed, 250 insertions(+), 190 deletions(-) delete mode 100644 packages/core/src/lib/pagination/layout.type.mts diff --git a/packages/core/src/lib/pagination/layout.type.mts b/packages/core/src/lib/pagination/layout.type.mts deleted file mode 100644 index 1150be732..000000000 --- a/packages/core/src/lib/pagination/layout.type.mts +++ /dev/null @@ -1,6 +0,0 @@ -/** Represents an item that's part of a layout, about to be rendered */ -export type Layout = { - item: T; - index: number; - isActive: boolean; -}; diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts index c61eec817..448527cbb 100644 --- a/packages/core/src/lib/pagination/lines.mts +++ b/packages/core/src/lib/pagination/lines.mts @@ -1,27 +1,33 @@ import { type Prettify } from '@inquirer/type'; -import { breakLines, rotate } from '../utils.mjs'; -import { type Layout } from './layout.type.mjs'; +import { breakLines } from '../utils.mjs'; -type Options = { - items: readonly T[]; - /** The width of a rendered line in characters. */ - width: number; - /** Renders an item as part of a page. */ - renderItem: (layout: Prettify>) => 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; +/** Represents an item that's part of a layout, about to be rendered */ +export type Layout = { + 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(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. - * @returns The rendered lines */ export function lines({ items, @@ -30,52 +36,59 @@ export function lines({ active, position: requested, pageSize, -}: Options): string[] { - const split = (content: string) => breakLines(content, width).split('\n'); +}: { + items: readonly T[]; + /** The width of a rendered line in characters. */ + width: number; + /** Renders an item as part of a page. */ + renderItem: (layout: Prettify>) => 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>((item, index) => ({ item, index, isActive: index === active, })); const layoutsInPage = rotate(active - requested, layouts).slice(0, pageSize); - const getLines = (index: number) => split(renderItem(layoutsInPage[index]!)); + const renderItemAt = (index: number) => split(renderItem(layoutsInPage[index]!), width); // Create a blank array of lines for the page - const page = new Array(pageSize); + const pageBuffer = new Array(pageSize); // Render the active item to decide the position - const activeLines = getLines(requested).slice(0, pageSize); + const activeItem = renderItemAt(requested).slice(0, pageSize); const position = - requested + activeLines.length <= pageSize - ? requested - : pageSize - activeLines.length; + requested + activeItem.length <= pageSize ? requested : pageSize - activeItem.length; - // Render the lines of the active item into the page - activeLines.forEach((line, index) => { - page[position + index] = line; - }); + // Add the lines of the active item into the page + pageBuffer.splice(position, activeItem.length, ...activeItem); - // Fill the next lines - let lineNumber = position + activeLines.length; - let layoutIndex = requested + 1; - while (lineNumber < pageSize && layoutIndex < layoutsInPage.length) { - for (const line of getLines(layoutIndex)) { - page[lineNumber++] = line; - if (lineNumber >= pageSize) break; + // 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; } - layoutIndex++; + layoutPointer++; } - // Fill the previous lines - lineNumber = position - 1; - layoutIndex = requested - 1; - while (lineNumber >= 0 && layoutIndex >= 0) { - for (const line of getLines(layoutIndex).reverse()) { - page[lineNumber--] = line; - if (lineNumber < 0) break; + // 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; } - layoutIndex--; + layoutPointer--; } - return page.filter((line) => typeof line === 'string'); + return pageBuffer.filter((line) => typeof line === 'string'); } diff --git a/packages/core/src/lib/pagination/lines.test.mts b/packages/core/src/lib/pagination/lines.test.mts index d9c834774..c65d5a301 100644 --- a/packages/core/src/lib/pagination/lines.test.mts +++ b/packages/core/src/lib/pagination/lines.test.mts @@ -1,26 +1,35 @@ import { describe, it, expect } from 'vitest'; import { lines } from './lines.mjs'; +function renderResult(result) { + return `\n${result.join('\n')}\n`; +} + describe('lines(...)', () => { const items = [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }]; const renderLines = - (count: number) => + (itemHeight: number) => ({ - index, item: { value }, isActive, + index, }: { item: { value: number }; isActive: boolean; index: number; }): string => - new Array(count) + new Array(itemHeight) .fill(0) .map((_, i) => { - const pointer = isActive ? '>' : ' '; - const prefix = i === 0 ? `${pointer} ${(index + 1).toString()}.` : ' '; - return `${prefix}${value} line ${i + 1}`; + if (i === 0) { + const pointer = isActive ? '>' : ' '; + const prefix = itemHeight === 1 ? '' : '┌'; + return `${pointer} ${prefix} value:${value} index:${index + 1}`; + } + + const prefix = i === itemHeight - 1 ? '└' : '├'; + return ` ${prefix} value:${value}`; }) .join('\n'); @@ -36,13 +45,15 @@ describe('lines(...)', () => { renderItem, width: 20, }); - expect(result).toEqual([ - ' 2 line 2', - ' 2 line 3', - '> 3.3 line 1', - ' 3 line 2', - ' 3 line 3', - ]); + expect(renderResult(result)).toMatchInlineSnapshot(` + " + ├ value:2 + └ value:2 + > ┌ value:3 index:3 + ├ value:3 + └ value:3 + " + `); }); }); @@ -58,13 +69,15 @@ describe('lines(...)', () => { renderItem, width: 20, }); - expect(result).toEqual([ - ' 2 line 4', - '> 3.3 line 1', - ' 3 line 2', - ' 3 line 3', - ' 3 line 4', - ]); + expect(renderResult(result)).toMatchInlineSnapshot(` + " + └ value:2 + > ┌ value:3 index:3 + ├ value:3 + ├ value:3 + └ value:3 + " + `); }); }); @@ -80,13 +93,15 @@ describe('lines(...)', () => { renderItem, width: 20, }); - expect(result).toEqual([ - '> 3.3 line 1', - ' 3 line 2', - ' 3 line 3', - ' 3 line 4', - ' 3 line 5', - ]); + expect(renderResult(result)).toMatchInlineSnapshot(` + " + > ┌ value:3 index:3 + ├ value:3 + ├ value:3 + ├ value:3 + └ value:3 + " + `); }); }); @@ -102,13 +117,35 @@ describe('lines(...)', () => { renderItem, width: 20, }); - expect(result).toEqual([ - '> 3.3 line 1', - ' 3 line 2', - ' 3 line 3', - ' 3 line 4', - ' 3 line 5', - ]); + expect(renderResult(result)).toMatchInlineSnapshot(` + " + > ┌ value:3 index:3 + ├ value:3 + ├ value:3 + ├ value:3 + ├ value:3 + " + `); + }); + + it('should scroll through the option', () => { + const result = lines({ + items, + active: 2, + pageSize: 5, + position: 2, + renderItem, + width: 20, + }); + expect(renderResult(result)).toMatchInlineSnapshot(` + " + > ┌ value:3 index:3 + ├ value:3 + ├ value:3 + ├ value:3 + ├ value:3 + " + `); }); }); }); diff --git a/packages/core/src/lib/pagination/position.mts b/packages/core/src/lib/pagination/position.mts index 726f7eb28..ce9a12f19 100644 --- a/packages/core/src/lib/pagination/position.mts +++ b/packages/core/src/lib/pagination/position.mts @@ -1,43 +1,46 @@ -type Change = { - previous: T; - current: T; -}; - -type PageInfo = { - active: Change; - total: number; - pageSize: number; -}; - -/** - * Given information about a page, decides the next position at which the active - * item should be rendered in the page. - */ -type PositionReducer = (info: PageInfo, pointer: number) => number; - /** * Creates the next position for the active item considering a finite list of * items to be rendered on a page. */ -export const finite: PositionReducer = ({ pageSize, total, active }) => { +export function finite({ + active, + pageSize, + total, +}: { + active: number; + pageSize: number; + total: number; +}): number { const middle = Math.floor(pageSize / 2); - if (total <= pageSize || active.current < middle) return active.current; - if (active.current >= total - middle) return active.current + pageSize - total; + if (total <= pageSize || active < middle) return active; + if (active >= total - middle) return active + pageSize - total; return middle; -}; +} /** * Creates the next position for the active item considering an infinitely * looping list of items to be rendered on the page. */ -export const infinite: PositionReducer = ({ active, total, pageSize }, pointer) => { - if (total <= pageSize) return active.current; - /** - * Move the position only when the user moves down, and when the - * navigation fits within a single page - */ - if (active.previous < active.current && active.current - active.previous < pageSize) +export function infinite({ + active, + lastActive, + total, + pageSize, + pointer, +}: { + active: number; + lastActive: number; + total: number; + pageSize: number; + pointer: number; +}): number { + if (total <= pageSize) return active; + + // Move the position only when the user moves down, and when the + // navigation fits within a single page + if (lastActive < active && active - lastActive < pageSize) { // Limit it to the middle of the list - return Math.min(Math.floor(pageSize / 2), pointer + active.current - active.previous); + return Math.min(Math.floor(pageSize / 2), pointer + active - lastActive); + } return pointer; -}; +} diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index 95091adcd..76d133945 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -1,13 +1,17 @@ import chalk from 'chalk'; -import cliWidth from 'cli-width'; import { type Prettify } from '@inquirer/type'; -import { readline } from '../hook-engine.mjs'; import { useRef } from '../use-ref.mjs'; -import { lines } from './lines.mjs'; +import { readlineWidth } from '../utils.mjs'; +import { lines, type Layout } from './lines.mjs'; import { finite, infinite } from './position.mjs'; -import { type Layout } from './layout.type.mjs'; -type Options = { +export function usePagination({ + items, + active, + renderItem, + pageSize = 7, + loop = true, +}: { items: readonly T[]; /** The index of the active item. */ active: number; @@ -17,37 +21,38 @@ type Options = { pageSize?: number; /** Allows creating an infinitely looping list. `true` if unspecified. */ loop?: boolean; -}; +}): string { + const state = useRef({ position: 0, lastActive: 0 }); + + const position = loop + ? infinite({ + active, + lastActive: state.current.lastActive, + total: items.length, + pageSize, + pointer: state.current.position, + }) + : finite({ + active, + total: items.length, + pageSize, + }); -export function usePagination({ - items, - active, - renderItem, - pageSize = 7, - loop = true, -}: Options): string { - const rl = readline(); - const width = cliWidth({ defaultWidth: 80, output: rl.output }); - const state = useRef({ - position: 0, - lastActive: 0, - }); - const position = (loop ? infinite : finite)( - { - active: { current: active, previous: state.current.lastActive }, - total: items.length, - pageSize, - }, - state.current.position, - ); state.current.position = position; state.current.lastActive = active; - return lines({ items, width, renderItem, active, position, pageSize }) - .concat( - items.length <= pageSize - ? [] - : [chalk.dim('(Use arrow keys to reveal more choices)')], - ) - .join('\n'); + const visibleLines = lines({ + items, + width: readlineWidth(), + renderItem, + active, + position, + pageSize, + }).join('\n'); + + if (items.length > pageSize) { + return `${visibleLines}\n${chalk.dim('(Use arrow keys to reveal more choices)')}`; + } + + return visibleLines; } diff --git a/packages/core/src/lib/utils.mts b/packages/core/src/lib/utils.mts index 6ad5f17d1..a065d774a 100644 --- a/packages/core/src/lib/utils.mts +++ b/packages/core/src/lib/utils.mts @@ -1,4 +1,6 @@ +import cliWidth from 'cli-width'; import wrapAnsi from 'wrap-ansi'; +import { readline } from './hook-engine.mjs'; /** * Force line returns at specific width. This function is ANSI code friendly and it'll @@ -7,8 +9,8 @@ import wrapAnsi from 'wrap-ansi'; * @param {number} width * @return {string} */ -export const breakLines = (content: string, width: number): string => - content +export function breakLines(content: string, width: number): string { + return content .split('\n') .flatMap((line) => wrapAnsi(line, width, { trim: false, hard: true }) @@ -16,20 +18,12 @@ export const breakLines = (content: string, width: number): string => .map((str) => str.trimEnd()), ) .join('\n'); +} /** - * Creates a 0-based index out of an integer, wrapping around if necessary. - * @param {number} max The maximum count - * @param {number} value The value to convert to index + * Returns the width of the active readline, or 80 as default value. + * @returns {number} */ -const index = (max: number, value: number) => ((value % max) + max) % max; - -/** - * 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 - */ -export const rotate = (count: number, items: readonly T[]): readonly T[] => { - const offset = index(items.length, count); - return items.slice(offset).concat(items.slice(0, offset)); -}; +export function readlineWidth(): number { + return cliWidth({ defaultWidth: 80, output: readline().output }); +} diff --git a/packages/demo/demos/select.mjs b/packages/demo/demos/select.mjs index 776bb0177..efe4237b8 100644 --- a/packages/demo/demos/select.mjs +++ b/packages/demo/demos/select.mjs @@ -1,6 +1,35 @@ import * as url from 'node:url'; import { select, Separator } from '@inquirer/prompts'; +const alphabet = [ + { value: 'A' }, + { value: 'B' }, + { value: 'C' }, + { value: 'D' }, + { value: 'E' }, + { value: 'F' }, + { value: 'G' }, + { value: 'H' }, + { value: 'I' }, + { value: 'J' }, + { value: 'K' }, + { value: 'L' }, + { value: 'M' }, + { value: 'N' }, + { value: 'O', description: 'Letter O, not number 0' }, + { value: 'P' }, + { value: 'Q' }, + { value: 'R' }, + { value: 'S' }, + { value: 'T' }, + { value: 'U' }, + { value: 'V' }, + { value: 'W' }, + { value: 'X' }, + { value: 'Y' }, + { value: 'Z' }, +]; + const demo = async () => { let answer; @@ -28,33 +57,18 @@ const demo = async () => { message: 'Select your favorite letter', choices: [ new Separator('== Alphabet (choices cycle as you scroll through) =='), - { value: 'A' }, - { value: 'B' }, - { value: 'C' }, - { value: 'D' }, - { value: 'E' }, - { value: 'F' }, - { value: 'G' }, - { value: 'H' }, - { value: 'I' }, - { value: 'J' }, - { value: 'K' }, - { value: 'L' }, - { value: 'M' }, - { value: 'N' }, - { value: 'O', description: 'Letter O, not number 0' }, - { value: 'P' }, - { value: 'Q' }, - { value: 'R' }, - { value: 'S' }, - { value: 'T' }, - { value: 'U' }, - { value: 'V' }, - { value: 'W' }, - { value: 'X' }, - { value: 'Y' }, - { value: 'Z' }, + ...alphabet, + ], + }); + console.log('Answer:', answer); + + answer = await select({ + message: 'Select your favorite letter (example without loop)', + choices: [ + new Separator('== Alphabet (choices cycle as you scroll through) =='), + ...alphabet, ], + loop: false, }); console.log('Answer:', answer); }; From d7679b7eb19df3647881d4d146739ff2c4b2a815 Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Sun, 24 Sep 2023 17:38:17 -0400 Subject: [PATCH 89/94] Remove unecessary tests (erroneously commited) --- .../core/src/lib/pagination/lines.test.mts | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/packages/core/src/lib/pagination/lines.test.mts b/packages/core/src/lib/pagination/lines.test.mts index c65d5a301..11bd240f9 100644 --- a/packages/core/src/lib/pagination/lines.test.mts +++ b/packages/core/src/lib/pagination/lines.test.mts @@ -127,25 +127,5 @@ describe('lines(...)', () => { " `); }); - - it('should scroll through the option', () => { - const result = lines({ - items, - active: 2, - pageSize: 5, - position: 2, - renderItem, - width: 20, - }); - expect(renderResult(result)).toMatchInlineSnapshot(` - " - > ┌ value:3 index:3 - ├ value:3 - ├ value:3 - ├ value:3 - ├ value:3 - " - `); - }); }); }); From c497e39672ec492f7c03ec8a612f8bd386bd5fcb Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Fri, 29 Sep 2023 20:47:59 +0100 Subject: [PATCH 90/94] Add tests around selectable items --- packages/checkbox/checkbox.test.mts | 81 +++++++++++++++++++++++++++++ packages/select/select.test.mts | 58 +++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index 2534d0b3c..d0818e429 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -99,6 +99,46 @@ describe('checkbox prompt', () => { 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 to select, to toggle all, to invert + selection, and 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', @@ -140,6 +180,47 @@ describe('checkbox prompt', () => { 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 to select, to toggle all, to invert + selection, and 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', diff --git a/packages/select/select.test.mts b/packages/select/select.test.mts index 450783ad2..5bb0cfd89 100644 --- a/packages/select/select.test.mts +++ b/packages/select/select.test.mts @@ -180,6 +180,34 @@ describe('select prompt', () => { 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(select, { + message: 'Select a number', + choices: [new Separator(), ...numberedChoices], + pageSize: 2, + loop: false, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Use arrow keys) + ────────────── + ❯ 1 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('up'); + events.keypress('up'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Use arrow keys) + ────────────── + ❯ 1 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual(1); + }); + it('does not scroll down beyond last item when not looping', async () => { const { answer, events, getScreen } = await render(select, { message: 'Select a number', @@ -208,6 +236,36 @@ describe('select prompt', () => { await expect(answer).resolves.toEqual(numberedChoices.length); }); + it('does not scroll down beyond last selectable item when not looping', async () => { + const { answer, events, getScreen } = await render(select, { + message: 'Select a number', + choices: [...numberedChoices, new Separator()], + pageSize: 3, + loop: false, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Use arrow keys) + ❯ 1 + 2 + 3 + (Use arrow keys to reveal more choices)" + `); + + numberedChoices.forEach(() => events.keypress('down')); + events.keypress('down'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + 11 + ❯ 12 + ────────────── + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual(numberedChoices.length); + }); + it('skip disabled options by arrow keys', async () => { const { answer, events, getScreen } = await render(select, { message: 'Select a topping', From 6ec6c878346cb22e5a33bafb848910ed169f7599 Mon Sep 17 00:00:00 2001 From: Sudarsan Balaji Date: Fri, 29 Sep 2023 20:48:20 +0100 Subject: [PATCH 91/94] Disallow scrolling past selectable items --- packages/checkbox/src/index.mts | 15 ++++++++++----- packages/select/src/index.mts | 15 ++++++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index b8966efcb..dbd16077c 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -78,14 +78,19 @@ export default createPrompt( const [items, setItems] = useState>>( choices.map((choice) => ({ ...choice })), ); - const [active, setActive] = useState(() => { - const selected = items.findIndex(isSelectable); - if (selected < 0) + const [options] = useState(() => { + const selected = items + .map((x, i) => [x, i] as const) + .filter(([x]) => isSelectable(x)); + if (selected.length === 0) throw new Error( '[checkbox prompt] No selectable choices. All choices are disabled.', ); return selected; }); + const first = options.at(0)![1]; + const last = options.at(-1)![1]; + const [active, setActive] = useState(first); const [showHelpTip, setShowHelpTip] = useState(true); useKeypress((key) => { @@ -93,8 +98,8 @@ export default createPrompt( setStatus('done'); done(items.filter(isChecked).map((choice) => choice.value)); } else if (isUpKey(key) || isDownKey(key)) { - if (!loop && active === 0 && isUpKey(key)) return; - if (!loop && active === items.length - 1 && isDownKey(key)) return; + if (!loop && active === first && isUpKey(key)) return; + if (!loop && active === last && isDownKey(key)) return; const offset = isUpKey(key) ? -1 : 1; let next = active; do { diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index f565760cd..eed1a850b 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -63,14 +63,19 @@ export default createPrompt( const firstRender = useRef(true); const prefix = usePrefix(); const [status, setStatus] = useState('pending'); - const [active, setActive] = useState(() => { - const selected = items.findIndex(isSelectable); - if (selected < 0) + const [options] = useState(() => { + const selected = items + .map((x, i) => [x, i] as const) + .filter(([x]) => isSelectable(x)); + if (selected.length === 0) throw new Error( '[select prompt] No selectable choices. All choices are disabled.', ); return selected; }); + const first = options.at(0)![1]; + const last = options.at(-1)![1]; + const [active, setActive] = useState(first); // Safe to assume the cursor position always point to a Choice. const selectedChoice = items[active] as Choice; @@ -80,8 +85,8 @@ export default createPrompt( setStatus('done'); done(selectedChoice.value); } else if (isUpKey(key) || isDownKey(key)) { - if (!loop && active === 0 && isUpKey(key)) return; - if (!loop && active === items.length - 1 && isDownKey(key)) return; + if (!loop && active === first && isUpKey(key)) return; + if (!loop && active === last && isDownKey(key)) return; const offset = isUpKey(key) ? -1 : 1; let next = active; do { From 647d5c9025cf11ee26d7cf8aa0dfe9f4086379b5 Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Fri, 29 Sep 2023 17:08:35 -0400 Subject: [PATCH 92/94] Checkbox/Select with memoized bounds instead of state --- packages/checkbox/src/index.mts | 27 +++++++++++++++------------ packages/select/src/index.mts | 24 ++++++++++++------------ 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index dbd16077c..4aba9ad67 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -4,6 +4,7 @@ import { useKeypress, usePrefix, usePagination, + useMemo, isUpKey, isDownKey, isSpaceKey, @@ -78,19 +79,21 @@ export default createPrompt( const [items, setItems] = useState>>( choices.map((choice) => ({ ...choice })), ); - const [options] = useState(() => { - const selected = items - .map((x, i) => [x, i] as const) - .filter(([x]) => isSelectable(x)); - if (selected.length === 0) + + const bounds = useMemo(() => { + const first = items.findIndex(isSelectable); + const last = items.findLastIndex(isSelectable); + + if (first < 0) { throw new Error( '[checkbox prompt] No selectable choices. All choices are disabled.', ); - return selected; - }); - const first = options.at(0)![1]; - const last = options.at(-1)![1]; - const [active, setActive] = useState(first); + } + + return { first, last }; + }, [items]); + + const [active, setActive] = useState(bounds.first); const [showHelpTip, setShowHelpTip] = useState(true); useKeypress((key) => { @@ -98,8 +101,8 @@ export default createPrompt( setStatus('done'); done(items.filter(isChecked).map((choice) => choice.value)); } else if (isUpKey(key) || isDownKey(key)) { - if (!loop && active === first && isUpKey(key)) return; - if (!loop && active === last && isDownKey(key)) return; + 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 { diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index eed1a850b..503b358d7 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -5,6 +5,7 @@ import { usePrefix, usePagination, useRef, + useMemo, isEnterKey, isUpKey, isDownKey, @@ -63,19 +64,18 @@ export default createPrompt( const firstRender = useRef(true); const prefix = usePrefix(); const [status, setStatus] = useState('pending'); - const [options] = useState(() => { - const selected = items - .map((x, i) => [x, i] as const) - .filter(([x]) => isSelectable(x)); - if (selected.length === 0) + + const bounds = useMemo(() => { + const first = items.findIndex(isSelectable); + const last = items.findLastIndex(isSelectable); + if (first < 0) throw new Error( '[select prompt] No selectable choices. All choices are disabled.', ); - return selected; - }); - const first = options.at(0)![1]; - const last = options.at(-1)![1]; - const [active, setActive] = useState(first); + return { first, last }; + }, [items]); + + const [active, setActive] = useState(bounds.first); // Safe to assume the cursor position always point to a Choice. const selectedChoice = items[active] as Choice; @@ -85,8 +85,8 @@ export default createPrompt( setStatus('done'); done(selectedChoice.value); } else if (isUpKey(key) || isDownKey(key)) { - if (!loop && active === first && isUpKey(key)) return; - if (!loop && active === last && isDownKey(key)) return; + 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 { From cd8a7dfc1d56bb8ef96d1dab8bdd7f9d36b7c653 Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Fri, 29 Sep 2023 17:16:59 -0400 Subject: [PATCH 93/94] Replace findLast* by Node 16 compat code --- packages/checkbox/src/index.mts | 2 +- packages/select/src/index.mts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 4aba9ad67..cc852712c 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -82,7 +82,7 @@ export default createPrompt( const bounds = useMemo(() => { const first = items.findIndex(isSelectable); - const last = items.findLastIndex(isSelectable); + const last = items.length - 1 - [...items].reverse().findIndex(isSelectable); if (first < 0) { throw new Error( diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 503b358d7..90216eac8 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -67,7 +67,7 @@ export default createPrompt( const bounds = useMemo(() => { const first = items.findIndex(isSelectable); - const last = items.findLastIndex(isSelectable); + const last = items.length - 1 - [...items].reverse().findIndex(isSelectable); if (first < 0) throw new Error( '[select prompt] No selectable choices. All choices are disabled.', From 5b085f683eb29fc8c61d369c9dc95667ac6121be Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Tue, 3 Oct 2023 16:25:19 -0400 Subject: [PATCH 94/94] Add TODO reminders for the ugly code --- packages/checkbox/src/index.mts | 1 + packages/select/src/index.mts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index cc852712c..b222182d9 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -82,6 +82,7 @@ export default createPrompt( const bounds = useMemo(() => { const first = items.findIndex(isSelectable); + // TODO: Replace with `findLastIndex` when it's available. const last = items.length - 1 - [...items].reverse().findIndex(isSelectable); if (first < 0) { diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index 90216eac8..b9d2d1834 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -67,6 +67,7 @@ export default createPrompt( const bounds = useMemo(() => { const first = items.findIndex(isSelectable); + // TODO: Replace with `findLastIndex` when it's available. const last = items.length - 1 - [...items].reverse().findIndex(isSelectable); if (first < 0) throw new Error(