| export interface ComboBoxProps { | ||
| readonly left: number; | ||
| readonly top: number; | ||
| readonly width: number; | ||
| readonly items: string[]; | ||
| readonly value: string; | ||
| onChange(value: string): void; | ||
| onEnter?(): void; | ||
| } |
+226
| /** | ||
| * @typedef {import("@farjs/blessed").BlessedProgram} BlessedProgram | ||
| * @typedef {import("@farjs/blessed").Widgets.BlessedElement} BlessedElement | ||
| * @typedef {import("./ListViewport").ListViewport} ListViewport | ||
| * @typedef {import("./TextInput").TextInputState} TextInputState | ||
| * @typedef {import("./ComboBox").ComboBoxProps} ComboBoxProps | ||
| */ | ||
| import React, { useRef, useState } from "react"; | ||
| import { createListViewport } from "./ListViewport.mjs"; | ||
| import PopupOverlay from "./popup/PopupOverlay.mjs"; | ||
| import Theme from "./theme/Theme.mjs"; | ||
| import TextInput from "./TextInput.mjs"; | ||
| import ComboBoxPopup from "./ComboBoxPopup.mjs"; | ||
| const h = React.createElement; | ||
| /** | ||
| * @param {ComboBoxProps} props | ||
| */ | ||
| const ComboBox = (props) => { | ||
| const { textInputComp, comboBoxPopup } = ComboBox; | ||
| const inputRef = /** @type {React.MutableRefObject<BlessedElement>} */ ( | ||
| useRef() | ||
| ); | ||
| const programRef = | ||
| /** @type {React.MutableRefObject<BlessedProgram | null>} */ (useRef(null)); | ||
| const autoCompleteTimeoutRef = | ||
| /** @type {React.MutableRefObject<NodeJS.Timeout | null>} */ (useRef(null)); | ||
| const [maybePopup, setPopup] = useState( | ||
| /** @type {ListViewport | null} */ (null) | ||
| ); | ||
| const [state, setState] = useState( | ||
| /** @type {TextInputState} */ (TextInput.createState()) | ||
| ); | ||
| const currTheme = Theme.useTheme(); | ||
| const theme = currTheme.popup.menu; | ||
| const arrowStyle = currTheme.popup.regular; | ||
| function showOrHidePopup() { | ||
| if (maybePopup) hidePopup(); | ||
| else { | ||
| showPopup( | ||
| createListViewport(0, props.items.length, ComboBoxPopup.maxItems) | ||
| ); | ||
| } | ||
| } | ||
| /** @type {(viewport: ListViewport) => void} */ | ||
| function showPopup(viewport) { | ||
| setPopup(viewport); | ||
| if (programRef.current) { | ||
| programRef.current.hideCursor(); | ||
| } | ||
| } | ||
| function hidePopup() { | ||
| setPopup(null); | ||
| if (programRef.current) { | ||
| programRef.current.showCursor(); | ||
| } | ||
| } | ||
| /** @type {(offset: number, index: number) => void} */ | ||
| function onSelectAction(offset, index) { | ||
| if (props.items.length > 0) { | ||
| props.onChange(props.items[offset + index]); | ||
| hidePopup(); | ||
| process.stdin.emit("keypress", undefined, { | ||
| name: "end", | ||
| ctrl: false, | ||
| meta: false, | ||
| shift: false, | ||
| }); | ||
| } | ||
| } | ||
| /** @type {(key: string) => void} */ | ||
| function onAutoCompleteAction(key) { | ||
| const value = | ||
| state.selStart !== -1 | ||
| ? props.value.slice(0, Math.min(state.selStart, props.value.length)) | ||
| : props.value; | ||
| const newValue = (() => { | ||
| if (key.length === 1) return `${value}${key}`; | ||
| if (key.startsWith("S-") && key.length > 2) { | ||
| return `${value}${key.slice(2).toUpperCase()}`; | ||
| } | ||
| if (key === "space") return `${value} `; | ||
| return value; | ||
| })(); | ||
| if (newValue !== value) { | ||
| if (autoCompleteTimeoutRef.current) { | ||
| global.clearTimeout(autoCompleteTimeoutRef.current); | ||
| autoCompleteTimeoutRef.current = null; | ||
| } | ||
| const existing = props.items.find((_) => _.startsWith(newValue)); | ||
| if (existing) { | ||
| autoCompleteTimeoutRef.current = global.setTimeout(() => { | ||
| props.onChange(existing); | ||
| process.stdin.emit("keypress", undefined, { | ||
| name: "end", | ||
| ctrl: false, | ||
| meta: false, | ||
| shift: true, | ||
| }); | ||
| }, 25); | ||
| } | ||
| } | ||
| } | ||
| /** @type {(keyFull: string) => boolean} */ | ||
| const onKeypress = (keyFull) => { | ||
| let processed = !!maybePopup; | ||
| switch (keyFull) { | ||
| case "escape": | ||
| case "tab": | ||
| hidePopup(); | ||
| break; | ||
| case "C-up": | ||
| case "C-down": | ||
| showOrHidePopup(); | ||
| processed = true; | ||
| break; | ||
| case "return": | ||
| if (maybePopup) { | ||
| onSelectAction(maybePopup.offset, maybePopup.focused); | ||
| } | ||
| break; | ||
| default: | ||
| if (maybePopup) { | ||
| const vp = maybePopup.onKeypress(keyFull); | ||
| if (vp) { | ||
| setPopup(vp); | ||
| } | ||
| } else onAutoCompleteAction(keyFull); | ||
| break; | ||
| } | ||
| return processed; | ||
| }; | ||
| return h( | ||
| React.Fragment, | ||
| null, | ||
| h(textInputComp, { | ||
| inputRef: inputRef, | ||
| left: props.left, | ||
| top: props.top, | ||
| width: props.width, | ||
| value: props.value, | ||
| state, | ||
| stateUpdater: setState, | ||
| onChange: props.onChange, | ||
| onEnter: props.onEnter, | ||
| onKeypress, | ||
| }), | ||
| h("text", { | ||
| width: 1, | ||
| height: 1, | ||
| left: props.left + props.width, | ||
| top: props.top, | ||
| clickable: true, | ||
| mouse: true, | ||
| autoFocus: false, | ||
| style: arrowStyle, | ||
| onClick: () => { | ||
| const el = inputRef.current; | ||
| if (el && el.screen.focused !== el) { | ||
| el.focus(); | ||
| } | ||
| showOrHidePopup(); | ||
| }, | ||
| content: ComboBox.arrowDownCh, | ||
| }), | ||
| maybePopup | ||
| ? h( | ||
| "form", | ||
| { | ||
| /** @type {(el?: BlessedElement) => void} */ | ||
| ref: (el) => { | ||
| if (el) { | ||
| programRef.current = el.screen.program; | ||
| } | ||
| }, | ||
| clickable: true, | ||
| mouse: true, | ||
| autoFocus: false, | ||
| style: PopupOverlay.style, | ||
| onClick: hidePopup, | ||
| }, | ||
| h(comboBoxPopup, { | ||
| left: props.left, | ||
| top: props.top + 1, | ||
| width: props.width, | ||
| items: props.items, | ||
| viewport: maybePopup, | ||
| setViewport: setPopup, | ||
| style: theme, | ||
| onClick: (index) => { | ||
| onSelectAction(0, index); | ||
| }, | ||
| }) | ||
| ) | ||
| : null | ||
| ); | ||
| }; | ||
| ComboBox.displayName = "ComboBox"; | ||
| ComboBox.textInputComp = TextInput; | ||
| ComboBox.comboBoxPopup = ComboBoxPopup; | ||
| ComboBox.arrowDownCh = "\u2193"; // ↓ | ||
| export default ComboBox; |
| export interface TextBoxProps { | ||
| readonly left: number; | ||
| readonly top: number; | ||
| readonly width: number; | ||
| readonly value: string; | ||
| onChange(value: string): void; | ||
| onEnter?(): void; | ||
| } |
| /** | ||
| * @typedef {import("@farjs/blessed").Widgets.BlessedElement} BlessedElement | ||
| * @typedef {import("./TextInput").TextInputState} TextInputState | ||
| * @typedef {import("./TextBox").TextBoxProps} TextBoxProps | ||
| */ | ||
| import React, { useRef, useState } from "react"; | ||
| import TextInput from "./TextInput.mjs"; | ||
| const h = React.createElement; | ||
| /** | ||
| * @param {TextBoxProps} props | ||
| */ | ||
| const TextBox = (props) => { | ||
| const { textInputComp } = TextBox; | ||
| const inputRef = /** @type {React.MutableRefObject<BlessedElement>} */ ( | ||
| useRef() | ||
| ); | ||
| const [state, setState] = useState( | ||
| /** @type {TextInputState} */ (TextInput.createState()) | ||
| ); | ||
| return h(textInputComp, { | ||
| inputRef: inputRef, | ||
| left: props.left, | ||
| top: props.top, | ||
| width: props.width, | ||
| value: props.value, | ||
| state, | ||
| stateUpdater: setState, | ||
| onChange: props.onChange, | ||
| onEnter: props.onEnter, | ||
| }); | ||
| }; | ||
| TextBox.displayName = "TextBox"; | ||
| TextBox.textInputComp = TextInput; | ||
| export default TextBox; |
| import React from "react"; | ||
| import { Widgets } from "@farjs/blessed"; | ||
| import { UiString } from "./UiString"; | ||
| type BlessedElement = Widgets.BlessedElement; | ||
| export interface TextInputProps { | ||
| readonly inputRef: React.MutableRefObject<BlessedElement>; | ||
| readonly left: number; | ||
| readonly top: number; | ||
| readonly width: number; | ||
| readonly value: string; | ||
| readonly state: TextInputState; | ||
| stateUpdater(update: (state: TextInputState) => TextInputState): void; | ||
| onChange(value: string): void; | ||
| onEnter?(): void; | ||
| onKeypress?(keyFull: string): boolean; | ||
| } | ||
| export interface TextInputState { | ||
| readonly offset: number; | ||
| readonly cursorX: number; | ||
| readonly selStart: number; | ||
| readonly selEnd: number; | ||
| } | ||
| export type CursorMove = | ||
| | CursorMove.At | ||
| | CursorMove.Home | ||
| | CursorMove.End | ||
| | CursorMove.Left | ||
| | CursorMove.Right; | ||
| export namespace CursorMove { | ||
| interface At { | ||
| readonly move: "At"; | ||
| readonly pos: number; | ||
| } | ||
| interface Home { | ||
| readonly move: "Home"; | ||
| } | ||
| interface End { | ||
| readonly move: "End"; | ||
| } | ||
| interface Left { | ||
| readonly move: "Left"; | ||
| readonly dx?: number; | ||
| } | ||
| interface Right { | ||
| readonly move: "Right"; | ||
| readonly dx?: number; | ||
| } | ||
| } | ||
| export type TextEdit = TextEdit.Insert | TextEdit.Delete | TextEdit.Backspace; | ||
| export namespace TextEdit { | ||
| interface Insert { | ||
| readonly edit: "Insert"; | ||
| readonly str: UiString; | ||
| } | ||
| interface Delete { | ||
| readonly edit: "Delete"; | ||
| } | ||
| interface Backspace { | ||
| readonly edit: "Backspace"; | ||
| } | ||
| } |
| /** | ||
| * @typedef {import("@farjs/blessed").Widgets.BlessedElement} BlessedElement | ||
| * @typedef {import("@farjs/blessed").Widgets.Events.IKeyEventArg & { | ||
| * defaultPrevented?: boolean | ||
| * }} IKeyEventArg | ||
| * @typedef {import("@farjs/blessed").Widgets.Events.IMouseEventArg} MouseEvent | ||
| * @typedef {import("./UiString").UiString} UiString | ||
| * @typedef {import("./TextInput").TextInputProps} TextInputProps | ||
| * @typedef {import("./TextInput").TextInputState} TextInputState | ||
| * @typedef {import("./TextInput").CursorMove} CursorMove | ||
| * @typedef {import("./TextInput").TextEdit} TextEdit | ||
| */ | ||
| import React, { useLayoutEffect, useRef } from "react"; | ||
| import { renderText2 } from "./UI.mjs"; | ||
| import UiString from "./UiString.mjs"; | ||
| import Theme from "./theme/Theme.mjs"; | ||
| const h = React.createElement; | ||
| /** | ||
| * @typedef {"Reset" | "All" | "TillTheHome" | "TillTheEnd" | "ToTheLeft" | "ToTheRight"} TextSelect | ||
| * @typedef {{value: string, cm: CursorMove}} EditResult | ||
| */ | ||
| /** | ||
| * @param {number} point | ||
| * @returns {boolean} | ||
| */ | ||
| function isHighSurrogate(point) { | ||
| return point >= 0xd800 && point <= 0xdbff; | ||
| } | ||
| /** | ||
| * @param {number} point | ||
| * @returns {boolean} | ||
| */ | ||
| function isLowSurrogate(point) { | ||
| return point >= 0xdc00 && point <= 0xdfff; | ||
| } | ||
| /** | ||
| * @param {TextInputProps} props | ||
| */ | ||
| const TextInput = (props) => { | ||
| const insertHighSurrogate = useRef(""); | ||
| const theme = Theme.useTheme().textBox; | ||
| const elementRef = props.inputRef; | ||
| const { offset, cursorX, selStart, selEnd } = props.state; | ||
| const currValue = UiString(props.value); | ||
| useLayoutEffect(() => { | ||
| move(elementRef.current, currValue, { move: "End" }, "All"); | ||
| }, []); | ||
| /** | ||
| * @param {BlessedElement} el | ||
| * @param {UiString} value | ||
| * @param {CursorMove} cm | ||
| * @param {TextSelect} ts | ||
| */ | ||
| function move(el, value, cm, ts) { | ||
| const charStart = value.charStartPos(offset + cursorX); | ||
| /** | ||
| * @param {number} [dx] | ||
| * @returns {number[]} | ||
| */ | ||
| function moveLeft(dx) { | ||
| const ldx = dx ?? Math.max(charStart.lcw, 1); | ||
| return [cursorX - ldx, cursorX === 0 ? offset - ldx : offset]; | ||
| } | ||
| /** | ||
| * @param {number} [dx] | ||
| * @returns {number[]} | ||
| */ | ||
| //prettier-ignore | ||
| function moveRight(dx) { | ||
| const rdx = dx ?? Math.max(charStart.rcw, 1); | ||
| return [cursorX + rdx, cursorX === /** @type {number} */ (el.width) - 1 ? offset + rdx : offset]; | ||
| } | ||
| const [posX, idx] = (() => { | ||
| //prettier-ignore | ||
| switch (cm.move) { | ||
| case "At": return [cm.pos, offset]; | ||
| case "Home": return [0, 0]; | ||
| case "End": return [value.strWidth(), value.strWidth() - /** @type {number} */ (el.width) + 1]; | ||
| case "Left": return moveLeft(cm.dx); | ||
| case "Right": return moveRight(cm.dx); | ||
| } | ||
| })(); | ||
| const newOffset = Math.min(Math.max(idx, 0), value.strWidth()); | ||
| const newPos = Math.min( | ||
| Math.max(posX, 0), | ||
| Math.min( | ||
| Math.max(/** @type {number} */ (el.width) - 1, 0), | ||
| Math.max(value.strWidth() - newOffset, 0) | ||
| ) | ||
| ); | ||
| if (newPos !== cursorX) { | ||
| el.screen.program.omove( | ||
| /** @type {number} */ (el.aleft) + newPos, | ||
| /** @type {number} */ (el.atop) | ||
| ); | ||
| } | ||
| select(value, charStart.pos, newOffset + newPos, ts); | ||
| props.stateUpdater((state) => { | ||
| return { ...state, offset: newOffset, cursorX: newPos }; | ||
| }); | ||
| } | ||
| /** | ||
| * @param {UiString} value | ||
| * @param {number} idx | ||
| * @param {number} newIdx | ||
| * @param {TextSelect} ts | ||
| */ | ||
| function select(value, idx, newIdx, ts) { | ||
| const selStartOrIdx = () => (selStart !== -1 ? selStart : idx); | ||
| const selEndOrIdx = () => (selEnd !== -1 ? selEnd : idx); | ||
| const [newStart, newEnd] = (() => { | ||
| //prettier-ignore | ||
| switch (ts) { | ||
| case "Reset": return [-1, -1]; | ||
| case "All": return [0, value.strWidth()]; | ||
| case "TillTheEnd": return [selStartOrIdx(), value.strWidth()]; | ||
| case "ToTheRight": return [selStartOrIdx(), newIdx]; | ||
| case "TillTheHome": return [0, selEndOrIdx()]; | ||
| case "ToTheLeft": return [newIdx, selEndOrIdx()]; | ||
| } | ||
| })(); | ||
| props.stateUpdater((state) => { | ||
| return { ...state, selStart: newStart, selEnd: newEnd }; | ||
| }); | ||
| } | ||
| /** | ||
| * @param {MouseEvent} data | ||
| */ | ||
| //prettier-ignore | ||
| function onClick(data) { | ||
| const el = elementRef.current; | ||
| const screen = el.screen; | ||
| move(el, currValue, { move: "At", pos: data.x - /** @type {number} */ (el.aleft) }, "Reset"); | ||
| if (screen.focused !== el) { | ||
| el.focus(); | ||
| } | ||
| } | ||
| function onResize() { | ||
| const el = elementRef.current; | ||
| const screen = el.screen; | ||
| if (screen.focused === el) { | ||
| screen.program.omove( | ||
| /** @type {number} */ (el.aleft) + cursorX, | ||
| /** @type {number} */ (el.atop) | ||
| ); | ||
| } | ||
| } | ||
| function onFocus() { | ||
| const el = elementRef.current; | ||
| const screen = el.screen; | ||
| const cursor = screen.cursor; | ||
| if (cursor.shape !== "underline" || !cursor.blink) { | ||
| // @ts-ignore | ||
| screen.cursorShape("underline", true); | ||
| } | ||
| screen.program.showCursor(); | ||
| } | ||
| function onBlur() { | ||
| const el = elementRef.current; | ||
| el.screen.program.hideCursor(); | ||
| } | ||
| /** | ||
| * @param {UiString} value | ||
| * @param {TextEdit} te | ||
| * @returns {EditResult} | ||
| */ | ||
| function edit(value, te) { | ||
| /** | ||
| * @returns {EditResult} | ||
| */ | ||
| function doEdit() { | ||
| //prettier-ignore | ||
| if (selEnd - selStart > 0) { | ||
| switch (te.edit) { | ||
| case "Delete": | ||
| case "Backspace": | ||
| return { | ||
| value: value.slice(0, selStart) + value.slice(selEnd, value.strWidth()), | ||
| cm: { move: "At", pos: selStart - offset }, | ||
| }; | ||
| case "Insert": | ||
| return { | ||
| value: value.slice(0, selStart) + te.str + value.slice(selEnd, value.strWidth()), | ||
| cm: { move: "At", pos: selStart + te.str.strWidth() - offset }, | ||
| }; | ||
| } | ||
| } else { | ||
| const idx = offset + cursorX; | ||
| const charStart = value.charStartPos(idx); | ||
| switch (te.edit) { | ||
| case "Delete": | ||
| return { | ||
| value: value.slice(0, idx) + value.slice(idx + charStart.rcw, value.strWidth()), | ||
| cm: { move: "At", pos: cursorX }, | ||
| }; | ||
| case "Backspace": | ||
| return { | ||
| value: value.slice(0, idx - 1) + value.slice(idx, value.strWidth()), | ||
| cm: { move: "Left", dx: charStart.lcw }, | ||
| }; | ||
| case "Insert": | ||
| return { | ||
| value: value.slice(0, idx) + te.str + value.slice(idx, value.strWidth()), | ||
| cm: { move: "Right", dx: te.str.strWidth() }, | ||
| }; | ||
| } | ||
| } | ||
| } | ||
| const res = doEdit(); | ||
| if (value.toString() !== res.value) { | ||
| props.onChange(res.value); | ||
| } | ||
| return res; | ||
| } | ||
| /** | ||
| * @param {object | null | undefined} ch | ||
| * @param {IKeyEventArg} key | ||
| */ | ||
| function onKeypress(ch, key) { | ||
| const el = elementRef.current; | ||
| /** | ||
| * @param {TextEdit} te | ||
| */ | ||
| function remove(te) { | ||
| const { value: newVal, cm: cursorMove } = edit(currValue, te); | ||
| if (currValue.toString() !== newVal) { | ||
| move(el, UiString(newVal), cursorMove, "Reset"); | ||
| } | ||
| } | ||
| let processed = true; | ||
| if (!props.onKeypress?.call(null, key.full)) { | ||
| //prettier-ignore | ||
| switch (key.full) { | ||
| case "return": props.onEnter?.call(null); break; | ||
| case "enter": break; // either enter or return is handled, not both! | ||
| case "escape": | ||
| case "tab": processed = false; break; | ||
| case "right": move(el, currValue, { move: "Right" }, "Reset"); break; | ||
| case "S-right": move(el, currValue, { move: "Right" }, "ToTheRight"); break; | ||
| case "left": move(el, currValue, { move: "Left" }, "Reset"); break; | ||
| case "S-left": move(el, currValue, { move: "Left" }, "ToTheLeft"); break; | ||
| case "home": move(el, currValue, { move: "Home" }, "Reset"); break; | ||
| case "S-home": move(el, currValue, { move: "Home" }, "TillTheHome"); break; | ||
| case "end": move(el, currValue, { move: "End" }, "Reset"); break; | ||
| case "S-end": move(el, currValue, { move: "End" }, "TillTheEnd"); break; | ||
| case "C-a": move(el, currValue, { move: "End" }, "All"); break; | ||
| case "C-c": | ||
| case "C-x": | ||
| if (selEnd - selStart > 0) { | ||
| el.screen.copyToClipboard(currValue.slice(selStart, selEnd)); | ||
| if (key.full === "C-x") { | ||
| remove({ edit: "Delete" }); | ||
| } | ||
| } | ||
| break; | ||
| case "delete": remove({ edit: "Delete" }); break; | ||
| case "backspace": remove({ edit: "Backspace" }); break; | ||
| default: | ||
| const str = ch?.toString(); | ||
| if (str && str.length > 0) { | ||
| const code = str.charCodeAt(0); | ||
| if (isHighSurrogate(code)) insertHighSurrogate.current = str; | ||
| else { | ||
| const combined = isLowSurrogate(code) ? insertHighSurrogate.current + str : str; | ||
| insertHighSurrogate.current = ""; | ||
| const { value: newVal, cm: cursorMove } = edit(currValue, { edit: "Insert", str: UiString(combined) }); | ||
| move(el, UiString(newVal), cursorMove, "Reset"); | ||
| } | ||
| } else processed = false; | ||
| break; | ||
| } | ||
| } | ||
| key.defaultPrevented = processed; | ||
| } | ||
| //prettier-ignore | ||
| function renderContent() { | ||
| if (selEnd - selStart > 0) { | ||
| const part1 = renderText2(theme.regular, currValue.slice(offset, selStart)); | ||
| const part2 = renderText2(theme.selected, currValue.slice(Math.max(selStart, offset), selEnd)); | ||
| const part3 = renderText2(theme.regular, currValue.slice(selEnd, currValue.strWidth())); | ||
| return `${part1}${part2}${part3}`; | ||
| } | ||
| return renderText2(theme.regular, currValue.slice(offset, currValue.strWidth())); | ||
| } | ||
| return h("input", { | ||
| ref: elementRef, | ||
| autoFocus: false, | ||
| clickable: true, | ||
| keyable: true, | ||
| width: props.width, | ||
| height: 1, | ||
| left: props.left, | ||
| top: props.top, | ||
| style: theme.regular, | ||
| wrap: false, | ||
| tags: true, | ||
| content: renderContent(), | ||
| onClick, | ||
| onResize, | ||
| onFocus, | ||
| onBlur, | ||
| onKeypress, | ||
| }); | ||
| }; | ||
| TextInput.displayName = "TextInput"; | ||
| /** | ||
| * @returns {TextInputState} | ||
| */ | ||
| TextInput.createState = () => { | ||
| return { | ||
| offset: 0, | ||
| cursorX: -1, | ||
| selStart: -1, | ||
| selEnd: -1, | ||
| }; | ||
| }; | ||
| export default TextInput; |
+2
-2
| { | ||
| "name": "@farjs/ui", | ||
| "author": "viktor-podzigun", | ||
| "version": "0.6.3", | ||
| "version": "0.7.0", | ||
| "license": "MIT", | ||
@@ -40,3 +40,3 @@ "description": "Terminal UI React.js components library", | ||
| "dependencies": { | ||
| "@farjs/blessed": "0.3.0", | ||
| "@farjs/blessed": "0.3.1", | ||
| "react": "^17.0.1", | ||
@@ -43,0 +43,0 @@ "react-blessed": "0.7.2" |
112326
20.57%101
6.32%3934
19.07%+ Added
- Removed
Updated