UNPKG

sc4

Version:

A command line utility for automating SimCity 4 modding tasks & modifying savegames

194 lines (193 loc) 7.83 kB
import path from 'node:path'; import os from 'node:os'; import { createPrompt, isBackspaceKey, isDownKey, isEnterKey, isSpaceKey, isUpKey, makeTheme, useKeypress, useMemo, usePagination, usePrefix, useState, } from '@inquirer/core'; import figures from '@inquirer/figures'; import chalk from 'chalk'; import checkUnicode from 'is-unicode-supported'; function checkWindowsUnicode() { const [major, , build] = os.release().split('.'); return +major > 10 || (+major === 10 && +build >= 22000); } // Windows 11 supports unicode in the terminal, which can be detected by the // build number being above 22000 const isUnicodeSupported = checkUnicode() || checkWindowsUnicode(); // Utils.ts import fs, { Stats } from 'node:fs'; const CURSOR_HIDE = '\x1B[?25l'; function isEscapeKey(key) { return key.name === 'escape'; } function ensureTrailingSlash(dir) { return dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`; } function stripAnsiCodes(str) { // eslint-disable-next-line no-control-regex return str.replace(/\x1B\[\d+m/g, ''); } function getMaxLength(arr) { return arr.reduce((max, item) => Math.max(max, stripAnsiCodes(item).length), 0); } function getDirFiles(dir) { return fs.readdirSync(dir).map((filename) => { try { const filepath = path.join(dir, filename); const fileStat = fs.statSync(filepath); return Object.assign(fileStat, { name: filename, path: filepath, isDisabled: false, }); } catch { return null; } }).filter(Boolean); } function sortFiles(files, showExcluded) { return files.sort((a, b) => { if (a.isDisabled && !b.isDisabled) { return 1; } if (!a.isDisabled && b.isDisabled) { return -1; } if (a.isDirectory() && !b.isDirectory()) { return -1; } if (!a.isDirectory() && b.isDirectory()) { return 1; } return a.name.localeCompare(b.name); }).filter((file) => showExcluded || !file.isDisabled); } const fileSelectorTheme = { prefix: { idle: chalk.cyan('?'), done: chalk.green(figures.tick), canceled: chalk.red(figures.cross), }, icon: { linePrefix: (item, isLast) => { let lines = isLast ? `${figures.lineUpRight}${figures.line.repeat(2)} ` : `${figures.lineUpDownRight}${figures.line.repeat(2)} `; if (isUnicodeSupported && item.isDirectory()) { lines += '📁 '; } return lines; }, }, style: { disabled: (text) => chalk.dim(text), active: (text) => chalk.cyan(text), cancelText: (text) => chalk.red(text), emptyText: (text) => chalk.red(text), directory: (text) => chalk.yellow(text), file: (text) => chalk.white(text), currentDir: (text) => chalk.magenta(text), message: (text, _status) => chalk.bold(text), help: (text) => chalk.white(text), key: (text) => chalk.cyan(text), }, }; export const fileSelector = createPrompt((config, done) => { const { type = 'file', pageSize = 10, loop = false, showExcluded = false, disabledLabel = ' (not allowed)', allowCancel = false, cancelText = 'Canceled.', emptyText = 'Directory is empty.', transform = (item) => item.name, } = config; const [status, setStatus] = useState('idle'); const theme = makeTheme(fileSelectorTheme, config.theme); const prefix = usePrefix({ status, theme }); const [currentDir, setCurrentDir] = useState(path.resolve(process.cwd(), config.basePath || '.')); const items = useMemo(() => { const files = getDirFiles(currentDir); for (const file of files) { file.isDisabled = config.filter ? !config.filter(file) : false; } return sortFiles(files, showExcluded); }, [currentDir]); const map = useMemo(() => ({}), []); const bounds = useMemo(() => { const first = items.findIndex((item) => !item.isDisabled); const last = items.findLastIndex((item) => !item.isDisabled); if (first === -1) { return { first: 0, last: 0 }; } return { first, last }; }, [items]); const [active, setActive] = useState(bounds.first); const activeItem = items[active]; useKeypress((key, rl) => { if (isEnterKey(key)) { if (activeItem.isDisabled || type === 'file' && activeItem.isDirectory() || type === 'directory' && !activeItem.isDirectory()) { return; } setStatus('done'); done(activeItem.path); } else if ((key.name === 'right' || isSpaceKey(key)) && activeItem.isDirectory()) { setCurrentDir(activeItem.path); setActive(map[activeItem.path] ?? bounds.first); } else if (isUpKey(key) || isDownKey(key)) { rl.clearLine(0); if (loop || isUpKey(key) && active !== bounds.first || isDownKey(key) && active !== bounds.last) { const offset = isUpKey(key) ? -1 : 1; map[currentDir] = active + offset; let next = active; do { next = (next + offset + items.length) % items.length; } while (items[next].isDisabled); setActive(next); } } else if (isBackspaceKey(key) || key.name === 'left') { let up = path.resolve(currentDir, '..'); setCurrentDir(up); setActive(map[up] ?? bounds.first); } else if (isEscapeKey(key) && allowCancel) { setStatus('canceled'); done('canceled'); } }); // The `usePagination` function is used to actually render the items on the // screen and make pagination possible. const page = usePagination({ items, active, renderItem({ item, index, isActive }) { const isLast = index === items.length - 1; const linePrefix = theme.icon.linePrefix(item, isLast); const name = transform(item); const line = item.isDirectory() ? `${linePrefix}${ensureTrailingSlash(name)}` : `${linePrefix}${name}`; if (item.isDisabled) { return theme.style.disabled(`${line}${disabledLabel}`); } const baseColor = item.isDirectory() ? theme.style.directory : theme.style.file; const color = isActive ? theme.style.active : baseColor; return color(line); }, pageSize, loop, }); // Render the rest of the information. const message = theme.style.message(config.message, status); if (status === 'canceled') { return `${prefix} ${message} ${theme.style.cancelText(cancelText)}`; } if (status === 'done') { return `${prefix} ${message} ${theme.style.answer(activeItem.path)}`; } const header = theme.style.currentDir(ensureTrailingSlash(currentDir)); const helpTip = useMemo(() => { const helpTipLines = [ `${theme.style.key(figures.arrowUp + figures.arrowDown)} navigate, ${theme.style.key('<enter>')} select${allowCancel ? `, ${theme.style.key('<esc>')} cancel` : ''}`, `${theme.style.key(figures.arrowRight)} open directory, ${theme.style.key(figures.arrowLeft)} go back`, ]; const helpTipMaxLength = getMaxLength(helpTipLines); const delimiter = figures.lineBold.repeat(helpTipMaxLength); return `${delimiter} ${helpTipLines.join('\n')}`; }, []); // At last, join everything together. return `${prefix} ${message} ${header} ${!page.length ? theme.style.emptyText(emptyText) : page} ${helpTip}${CURSOR_HIDE}`; });