Skip to main content
Module

x/cliffy/prompt/_generic_list.ts

Command line framework for deno 🦕 Including Commandline-Interfaces, Prompts, CLI-Table, Arguments Parser and more...
Extremely Popular
Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877
import type { KeyCode } from "../keycode/key_code.ts";import { GenericInput, GenericInputKeys, GenericInputPromptOptions, GenericInputPromptSettings,} from "./_generic_input.ts";import { WidenType } from "./_utils.ts";import { bold, brightBlue, dim, levenshteinDistance, stripAnsiCode, yellow,} from "./deps.ts";import { Figures, getFiguresByKeys } from "./_figures.ts";
type UnsupportedInputOptions = "suggestions" | "list";
/** Generic list prompt options. */export interface GenericListOptions<TValue, TReturnValue, TRawValue> extends Omit< GenericInputPromptOptions<TReturnValue, TRawValue>, UnsupportedInputOptions > { options: Array< | Extract<TValue, string | number> | Extract<WidenType<TValue>, string | number> | GenericListOption<TValue> | GenericListOptionGroup<TValue, GenericListOption<TValue>> | GenericListSeparatorOption >; /** Keymap to assign key names to prompt actions. */ keys?: GenericListKeys; /** Change list pointer. Default is `brightBlue("❯")`. */ listPointer?: string; /** Limit max displayed rows per page. */ maxRows?: number; /** Change search label. Default is `brightBlue("🔎")`. */ searchLabel?: string; /** Enable search. */ search?: boolean; /** Display prompt info. */ info?: boolean; /** Limit maximum amount of breadcrumb items. */ maxBreadcrumbItems?: number; /** Change breadcrumb separator. Default is ` › `. */ breadcrumbSeparator?: string; /** Change back pointer. Default is `❮`. */ backPointer?: string; /** Change group pointer. Default is `❯`. */ groupPointer?: string; /** Change group icon. Default is `📁`. */ groupIcon?: string | boolean; /** Change opened group icon. Default is `📂`. */ groupOpenIcon?: string | boolean; /** Format option value. */ format?: (value: TValue) => string;}
/** Generic list prompt settings. */export interface GenericListSettings< TValue, TReturnValue, TRawValue, TOption extends GenericListOptionSettings<TValue>, TGroup extends GenericListOptionGroupSettings<TValue, TOption>,> extends GenericInputPromptSettings<TReturnValue, TRawValue> { options: Array<TOption | TGroup>; keys: GenericListKeys; listPointer: string; maxRows: number; searchLabel: string; search?: boolean; info?: boolean; maxBreadcrumbItems: number; breadcrumbSeparator: string; backPointer: string; groupPointer: string; groupIcon: string | false; groupOpenIcon: string | false; format?: (value: TValue) => string;}
/** Generic list separator option options. */export interface GenericListSeparatorOption { /** The separator label. */ name: string;}
/** Generic list option options. */export interface GenericListOption<TValue> { /** The option value. */ value: TValue; /** The option label. */ name?: string; /** Disable option. Disabled options are displayed but cannot be selected. */ disabled?: boolean;}
/** Generic list option group options. */export interface GenericListOptionGroup< TValue, TOption extends GenericListOption<TValue>,> { /** The option label. */ name: string; /** An array of child options. */ options: Array< | Extract<TValue, string | number> | Extract<WidenType<TValue>, string | number> | TOption | this | GenericListSeparatorOption >; /** Disable option. Disabled options are displayed but cannot be selected. */ disabled?: boolean;}
/** Generic list option settings. */export interface GenericListOptionSettings<TValue> extends GenericListOption<TValue> { name: string; disabled: boolean; indentLevel: number;}
/** Generic list option group settings. */export interface GenericListOptionGroupSettings< TValue, TOption extends GenericListOptionSettings<TValue>,> extends GenericListOptionGroup<TValue, TOption> { disabled: boolean; indentLevel: number; options: Array<TOption | this>;}
/** GenericList key options. */export interface GenericListKeys extends GenericInputKeys { /** Select next option keymap. Default is `["down", "d", "n", "2"]`. */ next?: string[]; /** Select previous option keymap. Default is `["up", "u", "p", "8"]`. */ previous?: string[]; /** Select next page keymap. Default is `["pagedown", "right"]`. */ nextPage?: string[]; /** Select previous page keymap. Default is `["pageup", "left"]`. */ previousPage?: string[]; /** Select next option keymap. Default is `["right", "enter", "return"]`. */ open?: string[]; /** Select next option keymap. Default is `["left", "escape", "enter", "return"]`. */ back?: string[];}
interface MatchedOption< TValue, TOption extends GenericListOptionSettings<TValue>, TGroup extends GenericListOptionGroupSettings<TValue, TOption>,> { option: TOption | TGroup; distance: number; children: Array<MatchedOption<TValue, TOption, TGroup>>;}
/** Generic list prompt representation. */export abstract class GenericList< TValue, TReturnValue, TRawValue, TOption extends GenericListOptionSettings<TValue>, TGroup extends GenericListOptionGroupSettings<TValue, TOption>,> extends GenericInput<TReturnValue, TRawValue> { protected abstract readonly settings: GenericListSettings< TValue, TReturnValue, TRawValue, TOption, TGroup >; protected abstract options: Array<TOption | TGroup>; protected abstract listIndex: number; protected abstract listOffset: number; protected parentOptions: Array<TGroup> = [];
protected get selectedOption() { return this.options.at(this.listIndex); }
/** * Create list separator. * * @param label Separator label. */ public static separator(label = "------------"): GenericListSeparatorOption { return { name: label }; }
public getDefaultSettings( { groupIcon = true, groupOpenIcon = groupIcon, ...options }: GenericListOptions<TValue, TReturnValue, TRawValue>, ): GenericListSettings<TValue, TReturnValue, TRawValue, TOption, TGroup> { const settings = super.getDefaultSettings(options); return { ...settings, listPointer: options.listPointer ?? brightBlue(Figures.POINTER), searchLabel: options.searchLabel ?? brightBlue(Figures.SEARCH), backPointer: options.backPointer ?? brightBlue(Figures.POINTER_LEFT), groupPointer: options.groupPointer ?? options.listPointer ?? brightBlue(Figures.POINTER), groupIcon: !groupIcon ? false : typeof groupIcon === "string" ? groupIcon : Figures.FOLDER, groupOpenIcon: !groupOpenIcon ? false : typeof groupOpenIcon === "string" ? groupOpenIcon : Figures.FOLDER_OPEN, maxBreadcrumbItems: options.maxBreadcrumbItems ?? 5, breadcrumbSeparator: options.breadcrumbSeparator ?? ` ${Figures.POINTER_SMALL} `, maxRows: options.maxRows ?? 10, options: this.mapOptions(options, options.options), keys: { next: options.search ? ["down"] : ["down", "d", "n", "2"], previous: options.search ? ["up"] : ["up", "u", "p", "8"], nextPage: ["pagedown", "right"], previousPage: ["pageup", "left"], open: ["right", "enter", "return"], back: ["left", "escape", "enter", "return"], ...(settings.keys ?? {}), }, }; }
protected abstract mapOptions( promptOptions: GenericListOptions<TValue, TReturnValue, TRawValue>, options: Array< | Extract<TValue, string | number> | Extract<WidenType<TValue>, string | number> | GenericListOption<TValue> | GenericListOptionGroup<TValue, GenericListOption<TValue>> | GenericListSeparatorOption >, ): Array<TOption | TGroup>;
protected mapOption( options: GenericListOptions<TValue, TReturnValue, TRawValue>, option: GenericListOption<TValue> | GenericListSeparatorOption, ): GenericListOptionSettings<TValue> { if (isOption(option)) { return { value: option.value, name: typeof option.name === "undefined" ? options.format?.(option.value) ?? String(option.value) : option.name, disabled: "disabled" in option && option.disabled === true, indentLevel: 0, }; } else { return { value: null as TValue, name: option.name, disabled: true, indentLevel: 0, }; } }
protected mapOptionGroup( options: GenericListOptions<TValue, TReturnValue, TRawValue>, option: GenericListOptionGroup<TValue, GenericListOption<TValue>>, recursive = true, ): GenericListOptionGroupSettings<TValue, GenericListOptionSettings<TValue>> { return { name: option.name, disabled: !!option.disabled, indentLevel: 0, options: recursive ? this.mapOptions(options, option.options) : [], }; }
protected match(): void { const input: string = this.getCurrentInputValue().toLowerCase(); let options: Array<TOption | TGroup> = this.getCurrentOptions().slice();
if (input.length) { const matches = matchOptions<TValue, TOption, TGroup>( input, this.getCurrentOptions(), ); options = flatMatchedOptions(matches); }
this.setOptions(options); }
protected setOptions(options: Array<TOption | TGroup>) { this.options = [...options];
const parent = this.getParentOption(); if (parent && this.options[0] !== parent) { this.options.unshift(parent); }
this.listIndex = Math.max( 0, Math.min(this.options.length - 1, this.listIndex), ); this.listOffset = Math.max( 0, Math.min( this.options.length - this.getListHeight(), this.listOffset, ), ); }
protected getCurrentOptions(): Array<TOption | TGroup> { return this.getParentOption()?.options ?? this.settings.options; }
protected getParentOption(index = -1): TGroup | undefined { return this.parentOptions.at(index); }
protected submitBackButton() { const parentOption = this.parentOptions.pop(); if (!parentOption) { return; } this.match(); this.listIndex = this.options.indexOf(parentOption); }
protected submitGroupOption(selectedOption: TGroup) { this.parentOptions.push(selectedOption); this.match(); this.listIndex = 1; }
protected isBackButton(option: TOption | TGroup | undefined): boolean { return option === this.getParentOption(); }
protected hasParent(): boolean { return this.parentOptions.length > 0; }
protected isSearching(): boolean { return this.getCurrentInputValue() !== ""; }
protected message(): string { let message = `${this.settings.indent}${this.settings.prefix}` + bold(this.settings.message) + this.defaults();
if (this.settings.search) { const input = this.isSearchSelected() ? this.input() : dim(this.input()); message += " " + this.settings.searchLabel + " "; this.cursor.x = stripAnsiCode(message).length + this.inputIndex + 1; message += input; }
return message; }
/** Render options. */ protected body(): string | Promise<string> { return this.getList() + this.getInfo(); }
protected getInfo(): string { if (!this.settings.info) { return ""; } const selected: number = this.listIndex + 1; const hasGroups = this.options.some((option) => isOptionGroup(option));
const groupActions: Array<[string, Array<string>]> = hasGroups ? [ ["Open", getFiguresByKeys(this.settings.keys.open ?? [])], ["Back", getFiguresByKeys(this.settings.keys.back ?? [])], ] : [];
const actions: Array<[string, Array<string>]> = [ ["Next", getFiguresByKeys(this.settings.keys.next ?? [])], ["Previous", getFiguresByKeys(this.settings.keys.previous ?? [])], ...groupActions, ["Next Page", getFiguresByKeys(this.settings.keys.nextPage ?? [])], [ "Previous Page", getFiguresByKeys(this.settings.keys.previousPage ?? []), ], ["Submit", getFiguresByKeys(this.settings.keys.submit ?? [])], ];
return "\n" + this.settings.indent + brightBlue(Figures.INFO) + bold(` ${selected}/${this.options.length} `) + actions .map((cur) => `${cur[0]}: ${bold(cur[1].join(", "))}`) .join(", "); }
/** Render options list. */ protected getList(): string { const list: Array<string> = []; const height: number = this.getListHeight(); for (let i = this.listOffset; i < this.listOffset + height; i++) { list.push( this.getListItem( this.options[i], this.listIndex === i, ), ); } if (!list.length) { list.push( this.settings.indent + dim(" No matches..."), ); } return list.join("\n"); }
/** * Render option. * @param option Option. * @param isSelected Set to true if option is selected. */ protected getListItem( option: TOption | TGroup, isSelected?: boolean, ): string { let line = this.getListItemIndent(option); line += this.getListItemPointer(option, isSelected); line += this.getListItemIcon(option); line += this.getListItemLabel(option, isSelected);
return line; }
protected getListItemIndent(option: TOption | TGroup) { const indentLevel = this.isSearching() ? option.indentLevel : this.hasParent() && !this.isBackButton(option) ? 1 : 0;
return this.settings.indent + " ".repeat(indentLevel); }
protected getListItemPointer(option: TOption | TGroup, isSelected?: boolean) { if (!isSelected) { return " "; }
if (this.isBackButton(option)) { return this.settings.backPointer + " "; } else if (isOptionGroup(option)) { return this.settings.groupPointer + " "; }
return this.settings.listPointer + " "; }
protected getListItemIcon(option: TOption | TGroup): string { if (this.isBackButton(option)) { return this.settings.groupOpenIcon ? this.settings.groupOpenIcon + " " : ""; } else if (isOptionGroup(option)) { return this.settings.groupIcon ? this.settings.groupIcon + " " : ""; }
return ""; }
protected getListItemLabel( option: TOption | TGroup, isSelected?: boolean, ): string { let label = option.name;
if (this.isBackButton(option)) { label = this.getBreadCrumb(); label = isSelected && !option.disabled ? label : yellow(label); } else { label = isSelected && !option.disabled ? this.highlight(label, (val) => val) : this.highlight(label); }
if (this.isBackButton(option) || isOptionGroup(option)) { label = bold(label); }
return label; }
protected getBreadCrumb() { if (!this.parentOptions.length || !this.settings.maxBreadcrumbItems) { return ""; } const names = this.parentOptions.map((option) => option.name); const breadCrumb = names.length > this.settings.maxBreadcrumbItems ? [names[0], "..", ...names.slice(-this.settings.maxBreadcrumbItems + 1)] : names;
return breadCrumb.join(this.settings.breadcrumbSeparator); }
/** Get options row height. */ protected getListHeight(): number { return Math.min( this.options.length, this.settings.maxRows || this.options.length, ); }
protected getListIndex(value?: TValue) { return Math.max( 0, typeof value === "undefined" ? this.options.findIndex((option: TOption | TGroup) => !option.disabled ) || 0 : this.options.findIndex((option: TOption | TGroup) => isOption(option) && option.value === value ) || 0, ); }
protected getPageOffset(index: number) { if (index === 0) { return 0; } const height: number = this.getListHeight(); return Math.min( Math.floor(index / height) * height, this.options.length - height, ); }
/** * Find option by value. * @param value Value of the option. */ protected getOptionByValue( value: TValue, ): TOption | undefined { const option = this.options.find((option) => isOption(option) && option.value === value );
return option && isOptionGroup(option) ? undefined : option; }
/** Read user input. */ protected read(): Promise<boolean> { if (!this.settings.search) { this.settings.tty.cursorHide(); } return super.read(); }
protected selectSearch() { this.listIndex = -1; }
protected isSearchSelected(): boolean { return this.listIndex === -1; }
/** * Handle user input event. * @param event Key event. */ protected async handleEvent(event: KeyCode): Promise<void> { if ( this.isKey(this.settings.keys, "open", event) && isOptionGroup(this.selectedOption) && !this.isBackButton(this.selectedOption) && !this.isSearchSelected() ) { this.submitGroupOption(this.selectedOption); } else if ( this.isKey(this.settings.keys, "back", event) && (this.isBackButton(this.selectedOption) || event.name === "escape") && !this.isSearchSelected() ) { this.submitBackButton(); } else if (this.isKey(this.settings.keys, "next", event)) { this.selectNext(); } else if (this.isKey(this.settings.keys, "previous", event)) { this.selectPrevious(); } else if ( this.isKey(this.settings.keys, "nextPage", event) && !this.isSearchSelected() ) { this.selectNextPage(); } else if ( this.isKey(this.settings.keys, "previousPage", event) && !this.isSearchSelected() ) { this.selectPreviousPage(); } else { await super.handleEvent(event); } }
protected async submit(): Promise<void> { if (this.isSearchSelected()) { this.selectNext(); return; } await super.submit(); }
protected moveCursorLeft(): void { if (this.settings.search) { super.moveCursorLeft(); } }
protected moveCursorRight(): void { if (this.settings.search) { super.moveCursorRight(); } }
protected deleteChar(): void { if (this.settings.search) { super.deleteChar(); } }
protected deleteCharRight(): void { if (this.settings.search) { super.deleteCharRight(); this.match(); } }
protected addChar(char: string): void { if (this.settings.search) { super.addChar(char); this.match(); } }
/** Select previous option. */ protected selectPrevious(loop = true): void { if (this.options.length < 2 && !this.isSearchSelected()) { return; } if (this.listIndex > 0) { this.listIndex--; if (this.listIndex < this.listOffset) { this.listOffset--; } if (this.selectedOption?.disabled) { this.selectPrevious(); } } else if ( this.settings.search && this.listIndex === 0 && this.getCurrentInputValue().length ) { this.listIndex = -1; } else if (loop) { this.listIndex = this.options.length - 1; this.listOffset = this.options.length - this.getListHeight(); if (this.selectedOption?.disabled) { this.selectPrevious(); } } }
/** Select next option. */ protected selectNext(loop = true): void { if (this.options.length < 2 && !this.isSearchSelected()) { return; } if (this.listIndex < this.options.length - 1) { this.listIndex++; if (this.listIndex >= this.listOffset + this.getListHeight()) { this.listOffset++; } if (this.selectedOption?.disabled) { this.selectNext(); } } else if ( this.settings.search && this.listIndex === this.options.length - 1 && this.getCurrentInputValue().length ) { this.listIndex = -1; } else if (loop) { this.listIndex = this.listOffset = 0; if (this.selectedOption?.disabled) { this.selectNext(); } } }
/** Select previous page. */ protected selectPreviousPage(): void { if (this.options?.length) { const height: number = this.getListHeight(); if (this.listOffset >= height) { this.listIndex -= height; this.listOffset -= height; } else if (this.listOffset > 0) { this.listIndex -= this.listOffset; this.listOffset = 0; } else { this.listIndex = 0; } if (this.selectedOption?.disabled) { this.selectPrevious(false); } if (this.selectedOption?.disabled) { this.selectNext(false); } } }
/** Select next page. */ protected selectNextPage(): void { if (this.options?.length) { const height: number = this.getListHeight(); if (this.listOffset + height + height < this.options.length) { this.listIndex += height; this.listOffset += height; } else if (this.listOffset + height < this.options.length) { const offset = this.options.length - height; this.listIndex += offset - this.listOffset; this.listOffset = offset; } else { this.listIndex = this.options.length - 1; } if (this.selectedOption?.disabled) { this.selectNext(false); } if (this.selectedOption?.disabled) { this.selectPrevious(false); } } }}
export function isOption< TValue, TOption extends GenericListOption<TValue>,>( option: | TOption | GenericListOptionGroup<TValue, GenericListOption<TValue>> | GenericListSeparatorOption | undefined,): option is TOption { return !!option && typeof option === "object" && "value" in option;}
export function isOptionGroup< TValue, TGroup extends GenericListOptionGroup<TValue, GenericListOption<TValue>>,>( option: | TGroup | TValue | GenericListOption<TValue> | GenericListSeparatorOption | undefined,): option is TGroup { return option !== null && typeof option === "object" && "options" in option && Array.isArray(option.options);}
function matchOptions< TValue, TOption extends GenericListOptionSettings<TValue>, TGroup extends GenericListOptionGroupSettings<TValue, TOption>,>( searchInput: string, options: Array<TOption | TGroup>,): Array<MatchedOption<TValue, TOption, TGroup>> { const matched: Array<MatchedOption<TValue, TOption, TGroup>> = [];
for (const option of options) { if (isOptionGroup(option)) { const children = matchOptions(searchInput, option.options) .sort(sortByDistance);
if (children.length) { matched.push({ option, distance: Math.min(...children.map((item) => item.distance)), children, }); continue; } }
if (matchOption(searchInput, option)) { matched.push({ option, distance: levenshteinDistance(option.name, searchInput), children: [], }); } }
return matched.sort(sortByDistance);
function sortByDistance( a: MatchedOption<TValue, TOption, TGroup>, b: MatchedOption<TValue, TOption, TGroup>, ): number { return a.distance - b.distance; }}
function matchOption< TValue, TOption extends GenericListOptionSettings<TValue>, TGroup extends GenericListOptionGroupSettings<TValue, TOption>,>( inputString: string, option: TOption | TGroup,): boolean { return matchInput(inputString, option.name) || ( isOption(option) && option.name !== option.value && matchInput(inputString, String(option.value)) );}
function matchInput(inputString: string, value: string): boolean { return stripAnsiCode(value) .toLowerCase() .includes(inputString);}
function flatMatchedOptions< TValue, TOption extends GenericListOptionSettings<TValue>, TGroup extends GenericListOptionGroupSettings<TValue, TOption>,>( matches: Array<MatchedOption<TValue, TOption, TGroup>>, indentLevel = 0, result: Array<TOption | TGroup> = [],): Array<TOption | TGroup> { for (const { option, children } of matches) { option.indentLevel = indentLevel; result.push(option); flatMatchedOptions(children, indentLevel + 1, result); }
return result;}
/** * GenericList options type. * * @deprecated Use `Array<string | GenericListOption>` instead. */export type GenericListValueOptions = Array<string | GenericListOption<string>>;/** @deprecated Use `Array<GenericListOptionSettings>` instead. */export type GenericListValueSettings = Array<GenericListOptionSettings<string>>;