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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .talismanrc
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ fileignoreconfig:
- "a project name passed on argv to the project resolver"
- "expect\\(Object.keys\\(DEPENDENCIES\\)\\).toEqual\\(\\['project', 'env'\\]\\)"
- filename: AGENTS.md
checksum: c3276952d1eb16ea53f89363c9a70b59a19efc21fe087ea89ee70946c4b7cfc7
checksum: dcebcddea3f29849c0b287e9cf8786a4e306394fe96b9ca4136525298eee6379
allowed_patterns:
- "Do not add an exemption to a guard to make a new file pass"
- "passes its own error wording to `client.request\\(req, MESSAGES\\)`"
Expand Down
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,15 @@ replaced, and still sees every listed answer.
The same wrapper makes the pickers scroll like the lists: `PROMPT_PAGE_SIZE` rows at a time, and the
arrow stops at the first and last entry. Upstream, the up and down arrow handlers wrap round and the
bundled inquirer 3 paginator repeats the list three times to look endless, so the wrapper overrides both
arrow handlers and swaps the paginator for `scrollWindow`, a window that stops at both ends.
arrow handlers and swaps the paginator for `pageScroller`.

*Every list scrolls the same way.* `registerSearchList` also registers `list` and `checkbox` as
`launchPaginated` subclasses, so all three prompt types page through `pageScroller` and print
`(Use ↑/↓ to see more options)` under a list that scrolls. inquirer's own paginator holds the arrow in
the middle of the page and slides the list under it, then lets the arrow move again near the end;
`pageScroller` keeps the page still while the arrow moves to its top or bottom row, and only then
scrolls one row at a time. Each prompt gets its own scroller, since it remembers which row the page
starts at.

Together these are the prompt-side twin of `oneOf` on the flag side (`--cs-auth yes` has always been
exit 2); the two now agree, and no prompt module needs its own check.
Expand Down
68 changes: 45 additions & 23 deletions src/core/search-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
launchSearchList,
refuseUnlisted,
registerSearchList,
scrollWindow,
pageScroller,
startingPointer,
unlistedMessage,
utilitiesLoader,
Expand All @@ -20,7 +20,7 @@ const SearchList = load('inquirer-search-list') as SearchListClass;
interface Prompt {
opt: { validate: (value: unknown, answers?: unknown) => unknown; pageSize?: number };
pointer: number;
paginator: { paginate: unknown };
paginator: { paginate(output: string, active: number, pageSize?: number): string };
render(): void;
onDownKey(): void;
onUpKey(): void;
Expand Down Expand Up @@ -120,11 +120,11 @@ describe('launchSearchList', () => {
expect(prompt.pointer).toBe(0);
});

it('shows PROMPT_PAGE_SIZE choices at a time through a window that does not repeat the list', () => {
it('shows PROMPT_PAGE_SIZE choices at a time through the page scroller', () => {
const prompt = open({});

expect(prompt.opt.pageSize).toBe(PROMPT_PAGE_SIZE);
expect(prompt.paginator.paginate).toBe(scrollWindow);
expect(prompt.paginator.paginate(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].join('\n'), 0)).toContain(MORE_CHOICES_HINT);
});

it('keeps a page size the question sets itself', () => {
Expand Down Expand Up @@ -220,53 +220,75 @@ describe('launchSearchList', () => {
});
});

describe('scrollWindow', () => {
describe('pageScroller', () => {
const LINES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].join('\n');
const HINT = '\u001b[2m(Use ↑/↓ to see more options)\u001b[22m';
const shown = (active: number) => scrollWindow(LINES, active, 5).split('\n');

function walk(...actives: number[]): string[][] {
const scroller = pageScroller();

return actives.map((active) => scroller.paginate(LINES, active, 5).split('\n'));
}

it('shows a list that fits the page whole, with no hint', () => {
expect(scrollWindow('a\nb\nc', 2, 5)).toBe('a\nb\nc');
expect(pageScroller().paginate('a\nb\nc', 2, 5)).toBe('a\nb\nc');
});

it('starts at the top of a longer list and says there are more', () => {
expect(shown(0)).toEqual(['a', 'b', 'c', 'd', 'e', HINT]);
expect(walk(0)[0]).toEqual(['a', 'b', 'c', 'd', 'e', HINT]);
});

it('keeps the highlight in the middle while scrolling down', () => {
expect(shown(4)).toEqual(['c', 'd', 'e', 'f', 'g', HINT]);
it('keeps the page still while the arrow moves down to its last row', () => {
expect(walk(0, 1, 2, 3, 4)[4]).toEqual(['a', 'b', 'c', 'd', 'e', HINT]);
});

it('scrolls one row at a time once the arrow is on the last row, keeping it there', () => {
const pages = walk(0, 1, 2, 3, 4, 5, 6);

expect(pages[5]).toEqual(['b', 'c', 'd', 'e', 'f', HINT]);
expect(pages[6]).toEqual(['c', 'd', 'e', 'f', 'g', HINT]);
});

it('ends at the last choice and never shows the first one again after it', () => {
expect(shown(7)).toEqual(['d', 'e', 'f', 'g', 'h', HINT]);
expect(walk(0, 1, 2, 3, 4, 5, 6, 7, 7)[8]).toEqual(['d', 'e', 'f', 'g', 'h', HINT]);
});

it('keeps the page still while the arrow moves back up, and scrolls once it goes above the top row', () => {
const pages = walk(7, 6, 5, 4, 3, 2);

expect(pages[4]).toEqual(['d', 'e', 'f', 'g', 'h', HINT]);
expect(pages[5]).toEqual(['c', 'd', 'e', 'f', 'g', HINT]);
});

it('opens on the page that holds a choice offered further down, with it on the last row', () => {
expect(walk(6)[0]).toEqual(['c', 'd', 'e', 'f', 'g', HINT]);
});
});

describe('launchPaginated', () => {
interface Paginated {
paginator: { paginate(output: string, active: number): string };
paginator: { paginate(output: string, active: number, pageSize?: number): string };
}

class Upstream {
paginator = {
paginate: (output: string) => `${output}\n\u001b[2m(Move up and down to reveal more choices)\u001b[22m`,
};
paginator = { paginate: (output: string) => output };
}

it('points at the up and down arrows instead of the upstream wording', () => {
it('pages with its own scroller, which keeps the arrow moving and says how to see more', () => {
const Prompt = launchPaginated(Upstream as never) as unknown as new () => Paginated;
const prompt = new Prompt();
const lines = Array.from({ length: 10 }, (_, index) => `c${index}`).join('\n');

expect(new Prompt().paginator.paginate('a\nb', 0)).toBe(`a\nb\n\u001b[2m${MORE_CHOICES_HINT}\u001b[22m`);
expect(prompt.paginator.paginate(lines, 3, 7).split('\n')).toEqual([
'c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', `\u001b[2m${MORE_CHOICES_HINT}\u001b[22m`,
]);
expect(MORE_CHOICES_HINT).toBe('(Use ↑/↓ to see more options)');
});

it('leaves a page without the hint untouched', () => {
class Short {
paginator = { paginate: (output: string) => output };
}
const Prompt = launchPaginated(Short as never) as unknown as new () => Paginated;
it('gives each prompt a scroller of its own', () => {
const Prompt = launchPaginated(Upstream as never) as unknown as new () => Paginated;

expect(new Prompt().paginator.paginate('a\nb', 0)).toBe('a\nb');
expect(new Prompt().paginator).not.toBe(new Prompt().paginator);
});
});

Expand Down
46 changes: 26 additions & 20 deletions src/core/search-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,47 +58,53 @@ export function startingPointer(list: SearchChoice[], initial: unknown): number

export const MORE_CHOICES_HINT = '(Use ↑/↓ to see more options)';

const UPSTREAM_HINT = '(Move up and down to reveal more choices)';

interface Paginator {
paginate(output: string, active: number, pageSize?: number): string;
}

export type PaginatedClass = new (...params: never[]) => { paginator: Paginator };

export function pageScroller(): Paginator {
let top = 0;

return {
paginate(output: string, active: number, pageSize = PROMPT_PAGE_SIZE): string {
const lines = output.split('\n');

if (lines.length <= pageSize) {
return output;
}

if (active < top) {
top = active;
} else if (active >= top + pageSize) {
top = active - pageSize + 1;
}

top = Math.min(Math.max(top, 0), lines.length - pageSize);

return [...lines.slice(top, top + pageSize), styled(MORE_CHOICES_HINT, 'dim', true)].join('\n');
},
};
}

export function launchPaginated(Prompt: PaginatedClass): PaginatedClass {
return class LaunchPaginated extends Prompt {
constructor(...params: never[]) {
super(...params);
const paginate = this.paginator.paginate.bind(this.paginator);
this.paginator.paginate = (output, active, pageSize) =>
paginate(output, active, pageSize).replace(UPSTREAM_HINT, MORE_CHOICES_HINT);
this.paginator = pageScroller();
}
};
}

export function scrollWindow(output: string, active: number, pageSize = PROMPT_PAGE_SIZE): string {
const lines = output.split('\n');

if (lines.length <= pageSize) {
return output;
}

const top = Math.min(Math.max(active - Math.floor(pageSize / 2), 0), lines.length - pageSize);

return [...lines.slice(top, top + pageSize), styled(MORE_CHOICES_HINT, 'dim', true)].join(
'\n',
);
}

export function launchSearchList(SearchList: SearchListClass): SearchListClass {
return class LaunchSearchList extends SearchList {
constructor(...params: never[]) {
super(...params);
this.pointer = startingPointer(this.list, this.opt.default);
this.opt.validate = refuseUnlisted(this.opt.validate);
this.opt.pageSize = this.opt.pageSize ?? PROMPT_PAGE_SIZE;
this.paginator = { paginate: scrollWindow };
this.paginator = pageScroller();
}

onDownKey(): void {
Expand Down
Loading