🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@farjs/ui

Package Overview
Dependencies
Maintainers
1
Versions
36
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@farjs/ui - npm Package Compare versions

Comparing version
0.6.3
to
0.7.0
+9
src/ComboBox.d.ts
export interface ComboBoxProps {
readonly left: number;
readonly top: number;
readonly width: number;
readonly items: string[];
readonly value: string;
onChange(value: string): void;
onEnter?(): void;
}
/**
* @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"