Mari's guided journal software.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
mari-guided-journal/src/prompts/types/HierarchicalCheckboxInput.ts

672 lines
26 KiB

import Prompt from "inquirer/lib/prompts/base";
import observe from "inquirer/lib/utils/events";
import {
Answers,
ChoiceOptions,
HierarchicalCheckboxChoice,
HierarchicalCheckboxQuestion,
Question,
Separator,
SeparatorOptions,
} from "inquirer";
import {Interface as ReadLineInterface} from "readline";
import {filter} from "rxjs/operators"
import chalk from "chalk";
import {Subject} from "rxjs";
import figures from "figures";
import Paginator from "inquirer/lib/utils/paginator";
import {isPopulatedArray} from "../../utils/Arrays";
import {filter as fuzzyFilter, FilterOptions} from "fuzzy";
interface ExtendedReadLine extends ReadLineInterface {
line: string
cursor: number
}
declare module "inquirer" {
export interface HierarchicalCheckboxChoiceBase extends ChoiceOptions {
readonly value?: string|null
readonly children?: readonly HierarchicalCheckboxChoice[]
readonly showChildren?: boolean
}
export interface HierarchicalCheckboxParentChoice extends HierarchicalCheckboxChoiceBase {
readonly showChildren?: boolean
readonly value?: null
readonly children: readonly [HierarchicalCheckboxChoice, ...HierarchicalCheckboxChoice[]]
}
export interface HierarchicalCheckboxChildChoice extends HierarchicalCheckboxChoiceBase {
readonly value: string|null
readonly children?: readonly []
readonly showChildren?: false
}
export type HierarchicalCheckboxChoice = HierarchicalCheckboxParentChoice|HierarchicalCheckboxChildChoice
export interface HierarchicalCheckboxQuestionOptions {
readonly choices: readonly (HierarchicalCheckboxChoice|SeparatorOptions)[]
readonly pageSize?: number
}
export interface HierarchicalCheckboxQuestion<AnswerT extends Answers = Answers> extends Question<AnswerT>, HierarchicalCheckboxQuestionOptions {
/** @inheritDoc */
type: "hierarchical-checkbox"
default?: string[]
}
export interface QuestionMap {
["hierarchical-checkbox"]: HierarchicalCheckboxQuestion
}
}
interface NormalizedChoiceBase {
readonly name: string
readonly value: string|null
readonly short: string|null
readonly children?: readonly NormalizedChoice[]
}
interface NormalizedParentChoice extends NormalizedChoiceBase {
readonly name: string
readonly value: null
readonly short: null
readonly children: readonly [NormalizedChoice, ...NormalizedChoice[]]
readonly showChildren: boolean
}
interface NormalizedLeafChoice extends NormalizedChoiceBase {
readonly name: string
readonly value: string
readonly short: string
readonly children?: readonly []
}
interface NormalizedDisabledChoice extends NormalizedChoiceBase {
readonly name: string
readonly value: null
readonly short: null
readonly children?: readonly []
}
type NormalizedChoice = NormalizedParentChoice|NormalizedLeafChoice|NormalizedDisabledChoice
function isParentChoice(c: NormalizedChoice): c is NormalizedParentChoice {
return isPopulatedArray(c.children)
}
function isLeafChoice(c: NormalizedChoice): c is NormalizedLeafChoice {
return c.value !== null
}
function isDisabledChoice(c: NormalizedChoice): c is NormalizedDisabledChoice {
return c.value === null && !isPopulatedArray(c.children)
}
function normalizeChoice(choice: HierarchicalCheckboxChoice|SeparatorOptions, valueMap: ValueMap): NormalizedChoice {
if (choice.type === "separator") {
return {
name: choice.line || new Separator().line,
value: null,
short: null,
children: [],
}
}
const { name = chalk.dim("undefined") } = choice
const children = isPopulatedArray(choice.children) ? choice.children.map((choice) => normalizeChoice(choice, valueMap)) : [];
if (isPopulatedArray(children)) {
const { showChildren = true } = choice
return {
name,
value: null,
short: null,
children,
showChildren,
}
} else {
const { name: originalName = null } = choice
const { value = originalName } = choice
if (value === null) {
// Disabled choice, no need for a short value
return {
name,
value: null,
short: null,
children: [],
}
} else {
// Selectable leaf choice
const { short = name } = choice
const result: NormalizedLeafChoice = {
name,
value,
short,
children: []
}
addValueToMap(result, valueMap)
return result
}
}
}
interface BaseBreadcrumb {
readonly parent: Breadcrumb|null
readonly index: number|null
readonly children: readonly NormalizedChoice[]
readonly searchable: readonly (NormalizedLeafChoice|NormalizedParentChoice)[]
}
interface RootBreadcrumb extends BaseBreadcrumb {
readonly parent: null
readonly index: null
readonly selectable: readonly number[]
}
interface ChildBreadcrumb extends BaseBreadcrumb {
readonly parent: Breadcrumb
readonly index: number
readonly selectable: readonly number[]
}
interface FilterBreadcrumb extends BaseBreadcrumb {
readonly parent: Breadcrumb
readonly index: null
readonly filter: string
readonly adjustingFilter: boolean
}
type Breadcrumb = RootBreadcrumb|ChildBreadcrumb|FilterBreadcrumb
function isRootBreadcrumb(b: Breadcrumb): b is RootBreadcrumb {
return b.parent === null && b.index === null
}
function isFilterBreadcrumb(b: Breadcrumb): b is FilterBreadcrumb {
return b.parent !== null && b.index === null
}
interface ValueMap {
[value: string]: NormalizedLeafChoice|[NormalizedLeafChoice, NormalizedLeafChoice, ...NormalizedLeafChoice[]]
}
interface ValidValueMap extends ValueMap {
[value: string]: NormalizedLeafChoice
}
interface InvalidValueMap extends ValueMap {
[value: string]: [NormalizedLeafChoice, NormalizedLeafChoice, ...NormalizedLeafChoice[]]
}
function addValueToMap(choice: NormalizedLeafChoice, map: ValueMap) {
if (map.hasOwnProperty(choice.value)) {
const existing = map[choice.value]
if (Array.isArray(existing) && !existing.some((item) => item.name === choice.name && item.short === choice.short)) {
map[choice.value] = [choice, ...existing]
} else if (!Array.isArray(existing) && !(existing.name === choice.name && existing.short === choice.short)) {
map[choice.value] = [choice, existing]
} else {
// It's a duplicate, but one that's the same as something already in the books.
}
} else {
map[choice.value] = choice
}
}
function splitValues(map: ValueMap): {valid: ValidValueMap, invalid: InvalidValueMap} {
const valid: ValidValueMap = {}
const invalid: InvalidValueMap = {}
Object.keys(map).forEach((value) => {
const item = map[value]
if (Array.isArray(item)) {
invalid[value] = item
} else {
valid[value] = item
}
})
return { valid, invalid }
}
function getSearchableList(base: readonly NormalizedChoice[]): (NormalizedLeafChoice|NormalizedParentChoice)[] {
const result = base.flatMap((choice) => {
if (isParentChoice(choice)) {
return [choice, ...getSearchableList(choice.children)]
} else if (isLeafChoice(choice)) {
return [choice]
} else {
return []
}
})
result.sort((a: NormalizedLeafChoice|NormalizedParentChoice, b: NormalizedLeafChoice|NormalizedParentChoice): number => a.name.localeCompare(b.name))
return result.filter((value, index) => result.findIndex((match) => match.name === value.name && match.value === value.value) === index)
}
function getSelectableList(base: readonly NormalizedChoice[]): number[] {
return base.map((_, index) => index).filter((choice) => !isDisabledChoice(base[choice]))
}
const [searchHighlightPrefix, searchHighlightSuffix] = chalk.yellowBright.bold("Placeholder").split("Placeholder") as [string, string]
export class HierarchicalCheckboxInput<AnswerT extends Answers = Answers, QuestionT extends HierarchicalCheckboxQuestion<AnswerT> = HierarchicalCheckboxQuestion> extends Prompt<QuestionT> {
constructor(question: QuestionT, readline: ExtendedReadLine, answers: AnswerT) {
super(question, readline, answers);
const valueMap: ValueMap = {}
const rootChoices = question.choices.map((choice) => normalizeChoice(choice, valueMap))
this.location = {
parent: null,
index: null,
children: rootChoices,
selectable: getSelectableList(rootChoices),
searchable: getSearchableList(rootChoices)
}
const { valid, invalid } = splitValues(valueMap)
this.valueMap = valid
this.invalidValues = invalid
this.value = question.default?.slice() || []
this.activeIndex = 0
this.updateUnlistedValues()
}
// @ts-ignore
_run(callback: HierarchicalCheckboxInput["done"], error: (err: Error) => void) {
const invalidChoices = Object.keys(this.invalidValues);
const invalidValues = this.value.filter((value) => !this.valueMap.hasOwnProperty(value));
if (isPopulatedArray(invalidChoices)) {
error(Error(`Duplicate values: ${invalidChoices.join()}`))
} else if (isPopulatedArray(invalidValues)) {
error(Error(`Unknown values: ${invalidValues.join()}`))
}
this.rl.on("history", (history) => {
history.splice(0, history.length)
})
const events = observe(this.rl);
this.status = "touched"
this.done = callback
events.keypress.pipe(filter((event) => {
return event.key.name !== "up" && event.key.name !== "down" && event.key.name !== "tab" && event.key.name !== "backspace" && event.key.name !== "space" && !(event.key.name === "x" && event.key.ctrl === true)
})).subscribe(() => this.onOtherKey())
events.normalizedUpKey.subscribe(() => this.onUpKey())
events.normalizedDownKey.subscribe(() => this.onDownKey())
events.keypress.pipe(filter((event) => event.key.name === "tab")).subscribe(() => this.onTab())
events.keypress.pipe(filter((event) => event.key.name === "backspace")).subscribe(() => this.onBack())
events.keypress.pipe(filter((event) => event.key.name === "space")).subscribe(() => this.onSpacebar())
events.line.subscribe(() => this.onLine())
events.keypress.pipe(filter((event) => event.key.name === "x" && event.key.ctrl === true)).subscribe(() => this.onClear())
const onSubmit = this.handleSubmitEvents(this.submitter)
onSubmit.success.subscribe((result) => this.onValidated(result.value))
onSubmit.error.subscribe((result) => this.onValidationError(result.isValid))
this.scheduleRender()
}
private readonly valueMap: ValidValueMap
private readonly invalidValues: InvalidValueMap
private readonly value: string[]
private done: (state: any) => void|null
private location: Breadcrumb
private activeIndex: number
private unlistedValues: string[]
private submitter: Subject<string[]> = new Subject<string[]>()
// @ts-ignore
private paginator: Paginator = new Paginator(this.screen, {isInfinite: true});
private scheduledRender: NodeJS.Immediate|null = null
private get readline(): ExtendedReadLine {
return this.rl
}
private get activeChoice(): NormalizedChoice|null {
if (this.activeIndex >= this.totalLength) {
return null
}
if (this.isUnlistedActive()) {
return this.valueMap[this.unlistedValues[this.activeUnlistedIndex()]] || null
} else {
return this.location.children[this.activeChildIndex()] || null
}
}
private render(error?: string) {
this.scheduledRender = null
const outputs: string[] = [this.getQuestion()]
const bottomOutputs: string[] = []
const isRoot = isRootBreadcrumb(this.location)
const isFilter = isFilterBreadcrumb(this.location)
const hasFilter = isFilterBreadcrumb(this.location) && this.location.filter !== ""
const isAdjustingFilter = isFilterBreadcrumb(this.location) && this.location.adjustingFilter
const isParent = this.activeChoice && isParentChoice(this.activeChoice)
const isLeaf = this.activeChoice && isLeafChoice(this.activeChoice)
if (this.status === "answered") {
outputs.push(this.value.map((s) => `${chalk.cyan(this.valueMap[s].short)}`).join(", "))
} else {
if (typeof error === "string") {
bottomOutputs.push(`${figures.warning} ${error}`)
} else {
if (isFilterBreadcrumb(this.location)) {
outputs.push(isAdjustingFilter ? chalk.cyan("Searching: ") : chalk.dim("Searching: "))
outputs.push(this.location.filter)
}
bottomOutputs.push(this.value.map((s) => this.valueMap[s].short).join(", ") || chalk.dim("(nothing selected)"))
if (isAdjustingFilter) {
bottomOutputs.push(chalk.dim(`Type to search, ${hasFilter ? "" : `${chalk.yellow("<backspace>")} to stop searching, `}`
+ `${chalk.yellow("<tab>")} to switch to the search results, `
+ `${chalk.yellow("<Ctrl>+x")} to stop searching, ${chalk.yellow("<enter>")} to select the current option and finish the search.`))
} else {
bottomOutputs.push(chalk.dim(`Use arrow keys to move, ${isRoot ? "" : isFilter ? hasFilter ? "" : `${chalk.yellow("<backspace>")} to stop searching, ` : `${chalk.yellow("<backspace>")} to ascend, `}`
+ (isFilter ? `${chalk.yellow("<tab>")} to switch to the search box, ` : `${chalk.yellow("<tab>")} to search this view, `)
+ `${isParent ? `${chalk.yellow("<space>")} to descend, ` : isLeaf ? `${chalk.yellow("<space>")} to toggle, ` : ""}`
+ `${isFilter ? `${chalk.yellow("<Ctrl>+x")} to stop searching, ` : isPopulatedArray(this.value) ? `${chalk.yellow("<Ctrl>+x")} to clear selection, ` : ""}${isFilter ? `${chalk.yellow("<enter>")} to select the current option and finish the search.` : `${chalk.yellow("<enter>")} to submit.`}`))
}
}
bottomOutputs.push(this.renderList())
}
this.screen.render(outputs.join(""), bottomOutputs.join("\n"))
}
private renderList(): string {
const outputs: string[] = []
const isAdjustingFilter = isFilterBreadcrumb(this.location) && this.location.adjustingFilter
outputs.push(...this.location.children.map((entry, index):string => {
const isSelected = index === this.activeChildIndex()
const isActive = isLeafChoice(entry) && this.value.includes(entry.value)
const isDisabled = isDisabledChoice(entry)
const isParent = isParentChoice(entry)
const showChildren = isParentChoice(entry) && entry.showChildren
return `${isSelected ? chalk.cyan(figures.pointer) : " "} ${isDisabled ? " " : isParent ? figures.arrowRight : isActive ? chalk.greenBright(figures.checkboxCircleOn) : figures.checkboxCircleOff} ${isSelected && !isAdjustingFilter ? (isActive ? chalk.blueBright(entry.name) : chalk.cyan(entry.name)) : (isActive ? chalk.green(entry.name) : entry.name)}${isParentChoice(entry) && showChildren ? `${chalk.dim(` (${entry.children.map((child) => child.name).join(", ")})`)}` : ""}`
}))
if (!isFilterBreadcrumb(this.location) && isPopulatedArray(this.unlistedValues)) {
outputs.push(" " + new Separator().line)
outputs.push(...this.unlistedValues.map((value, index): string => {
const isSelected = index === this.activeUnlistedIndex()
const isActive = this.value.includes(value)
const entry = this.valueMap[value]
return `${isSelected ? chalk.cyan(figures.pointer) : " "} ${isActive ? chalk.greenBright(figures.checkboxCircleOn) : figures.checkboxCircleOff} ${isSelected ? (isActive ? chalk.blueBright(entry.name) : chalk.cyan(entry.name)) : (isActive ? chalk.green(entry.name) : entry.name)}`
}))
}
outputs.push(" " + new Separator().line)
// @ts-ignore
return this.paginator.paginate(outputs.join("\n"), this.activeIndex, this.opt.pageSize)
}
private isUnlistedActive() {
return !isFilterBreadcrumb(this.location) && this.activeUnlistedIndex() >= 0
}
private activeUnlistedIndex() {
if (isFilterBreadcrumb(this.location) || (this.activeIndex < this.location.selectable.length)) {
return -1;
} else {
return this.activeIndex - this.location.selectable.length;
}
}
private activeChildIndex() {
if (isFilterBreadcrumb(this.location)) {
return this.activeIndex
} else {
if (this.isUnlistedActive()) {
return -1
} else {
return this.location.selectable[this.activeIndex];
}
}
}
private onUpKey() {
if (isFilterBreadcrumb(this.location)) {
if (this.location.adjustingFilter) {
this.location = {
...this.location,
adjustingFilter: false
}
}
}
if (this.activeIndex > 0) {
this.changeIndex(-1)
} else {
this.activeIndex = this.totalLength
this.changeIndex(-1)
}
this.scheduleRender()
}
private onDownKey() {
if (isFilterBreadcrumb(this.location)) {
if (this.location.adjustingFilter) {
this.location = {
...this.location,
adjustingFilter: false
}
}
}
if (this.activeIndex < this.totalLength - 1) {
this.changeIndex(+1)
} else {
this.activeIndex = -1
this.changeIndex(+1)
}
this.scheduleRender()
}
private onTab() {
if (!isFilterBreadcrumb(this.location)) {
this.readline.line = ""
this.readline.cursor = 0
this.updateFilter()
} else if (this.location.adjustingFilter) {
this.location = {
...this.location,
adjustingFilter: false
}
this.readline.line = this.location.filter
this.readline.cursor = this.location.filter.length
} else {
this.location = {
...this.location,
adjustingFilter: true
}
this.readline.line = this.location.filter
this.readline.cursor = this.location.filter.length
}
this.scheduleRender()
}
private onBack() {
if (isFilterBreadcrumb(this.location)) {
if (!this.location.adjustingFilter && (this.location.filter !== this.readline.line || this.readline.cursor !== this.location.filter.length)) {
this.location = {
...this.location,
adjustingFilter: true
}
}
if (this.location.filter !== "") {
this.updateFilter()
this.scheduleRender()
return
}
}
const oldLocation = this.location
if (isRootBreadcrumb(oldLocation)) {
return
}
if (isFilterBreadcrumb(oldLocation)) {
this.location = oldLocation.parent
this.activeIndex = 0
} else if (isFilterBreadcrumb(oldLocation.parent)) {
this.location = oldLocation.parent.parent
this.activeIndex = 0
} else {
this.location = oldLocation.parent
this.activeIndex = oldLocation.index
}
this.updateUnlistedValues()
this.updatePrompt()
this.scheduleRender()
}
private onSpacebar() {
if (isFilterBreadcrumb(this.location) && this.location.adjustingFilter) {
this.updateFilter()
this.scheduleRender()
return
}
const currentChoice = this.activeChoice
if (currentChoice === null) {
return
} else if (isLeafChoice(currentChoice)) {
const index = this.value.indexOf(currentChoice.value)
if (index === -1) {
this.value.push(currentChoice.value)
} else {
this.value.splice(index, 1)
}
this.scheduleRender()
} else if (isParentChoice(currentChoice)) {
this.location = {
parent: this.location,
index: this.activeIndex,
children: currentChoice.children,
selectable: getSelectableList(currentChoice.children),
searchable: getSearchableList(currentChoice.children),
}
this.activeIndex = 0
this.updateUnlistedValues()
this.scheduleRender()
} else {
// disabled choice, do nothing
}
}
private updateUnlistedValues() {
if (isFilterBreadcrumb(this.location)) {
this.unlistedValues = []
} else {
this.unlistedValues = this.value.filter((value) => !this.location.children.some((choice) => choice.value === value))
}
}
private updateFilter() {
if (!isFilterBreadcrumb(this.location)) {
this.location = {
parent: this.location,
index: null,
filter: "",
adjustingFilter: true,
children: this.location.searchable,
searchable: this.location.searchable,
}
this.activeIndex = 0
this.updatePrompt()
}
if (this.location.adjustingFilter && this.readline.line !== this.location.filter) {
this.location = {
...this.location,
filter: this.readline.line,
children: this.readline.line === "" ? this.location.searchable : fuzzyFilter(this.readline.line, this.location.searchable.slice(), {
pre: searchHighlightPrefix,
post: searchHighlightSuffix,
extract(input: any): string {
return input.name
}
} as FilterOptions<NormalizedParentChoice | NormalizedLeafChoice>).map((result) => ({
...result.original,
name: result.string,
}))
}
this.activeIndex = 0
}
}
private onLine() {
if (isFilterBreadcrumb(this.location)) {
if (!isPopulatedArray(this.location.children)) {
return
}
if (this.location.adjustingFilter) {
this.location = {
...this.location,
adjustingFilter: false,
}
}
// Activate the selected item.
this.onSpacebar()
const newLocation = this.location
if (isFilterBreadcrumb(newLocation)) {
this.location = this.location.parent
}
this.updateUnlistedValues()
this.updatePrompt()
this.scheduleRender()
} else {
this.submitter.next(this.value.slice())
}
}
private onClear() {
if (isFilterBreadcrumb(this.location)) {
this.location = this.location.parent
this.activeIndex = 0
this.updateUnlistedValues()
this.updatePrompt()
this.scheduleRender()
}
this.value.splice(0, this.value.length)
this.scheduleRender()
}
private scheduleRender() {
if (this.scheduledRender === null) {
this.scheduledRender = setImmediate(() => this.render())
}
}
private changeIndex(by: number) {
this.activeIndex += by
if (this.activeIndex < 0) {
this.activeIndex = 0
} else if (this.activeIndex >= this.totalLength) {
this.activeIndex = this.totalLength - 1
}
}
private get totalLength() {
if (isFilterBreadcrumb(this.location)) {
return this.location.children.length;
} else {
return this.location.selectable.length + this.unlistedValues.length;
}
}
private onValidationError(isValid: false|string) {
this.render(typeof isValid === "string" ? chalk.red(isValid) : chalk.dim.red("Invalid input"))
}
private onValidated(value: string[]) {
this.value.splice(0, this.value.length, ...value)
this.status = "answered"
this.render()
this.screen.done()
this.done(value)
}
private onOtherKey() {
if (isFilterBreadcrumb(this.location)) {
if (!this.location.adjustingFilter && (this.location.filter !== this.readline.line || this.readline.cursor !== this.location.filter.length)) {
this.location = {
...this.location,
adjustingFilter: true
}
}
this.updateFilter()
} else if (this.readline.line !== "") {
this.updateFilter()
}
this.scheduleRender()
}
private updatePrompt() {
if (isFilterBreadcrumb(this.location)) {
this.readline.setPrompt("Searching: ")
} else {
this.readline.setPrompt("")
}
}
}