| import { Widgets } from "@farjs/blessed"; | ||
| export interface ListBoxProps { | ||
| readonly left: number; | ||
| readonly top: number; | ||
| readonly width: number; | ||
| readonly height: number; | ||
| readonly style: Widgets.Types.TStyle; | ||
| readonly items: string[]; | ||
| readonly selected: number; | ||
| onAction(index: number): void; | ||
| onSelect?(index: number): void; | ||
| } |
| /** | ||
| * @typedef {import("@farjs/blessed").Widgets.Events.IKeyEventArg} IKeyEventArg | ||
| * @typedef {import("./ListBox").ListBoxProps} ListBoxProps | ||
| */ | ||
| import React, { useLayoutEffect, useState } from "react"; | ||
| import { createListViewport } from "./ListViewport.mjs"; | ||
| import ListView from "./ListView.mjs"; | ||
| import ScrollBar from "./ScrollBar.mjs"; | ||
| const h = React.createElement; | ||
| /** | ||
| * @param {ListBoxProps} props | ||
| */ | ||
| const ListBox = (props) => { | ||
| const { listViewComp, scrollBarComp } = ListBox; | ||
| const [viewport, setViewport] = useState( | ||
| createListViewport(props.selected, props.items.length, props.height) | ||
| ); | ||
| const selected = viewport.offset + viewport.focused; | ||
| /** @type {(ch: any, key: IKeyEventArg) => void} */ | ||
| const onKeypress = (_, key) => { | ||
| switch (key.full) { | ||
| case "return": | ||
| props.onAction(selected); | ||
| break; | ||
| default: | ||
| const vp = viewport.onKeypress(key.full); | ||
| if (vp) { | ||
| setViewport(vp); | ||
| } | ||
| break; | ||
| } | ||
| }; | ||
| useLayoutEffect(() => { | ||
| props.onSelect?.call(null, selected); | ||
| }, [selected]); | ||
| return h( | ||
| "button", | ||
| { | ||
| left: props.left, | ||
| top: props.top, | ||
| width: props.width, | ||
| height: props.height, | ||
| onKeypress, | ||
| }, | ||
| h(listViewComp, { | ||
| left: 0, | ||
| top: 0, | ||
| width: props.width, | ||
| height: props.height, | ||
| items: props.items, | ||
| viewport, | ||
| setViewport, | ||
| style: props.style, | ||
| onClick: props.onAction, | ||
| }), | ||
| viewport.length > viewport.viewLength | ||
| ? h(scrollBarComp, { | ||
| left: props.width, | ||
| top: 0, | ||
| length: viewport.viewLength, | ||
| style: props.style, | ||
| value: viewport.offset, | ||
| extent: viewport.viewLength, | ||
| min: 0, | ||
| max: viewport.length - viewport.viewLength, | ||
| onChange: (offset) => { | ||
| setViewport(viewport.updated(offset)); | ||
| }, | ||
| }) | ||
| : null | ||
| ); | ||
| }; | ||
| ListBox.displayName = "ListBox"; | ||
| ListBox.listViewComp = ListView; | ||
| ListBox.scrollBarComp = ScrollBar; | ||
| export default ListBox; |
| /// <reference types="node" /> | ||
| import { Widgets } from "@farjs/blessed"; | ||
| import { ListViewport } from "./ListViewport"; | ||
| export interface ListViewProps { | ||
| readonly left: number; | ||
| readonly top: number; | ||
| readonly width: number; | ||
| readonly height: number; | ||
| readonly items: string[]; | ||
| readonly viewport: ListViewport; | ||
| setViewport(viewport: ListViewport): void; | ||
| readonly style: Widgets.Types.TStyle; | ||
| onClick(index: number): void; | ||
| } |
| /** | ||
| * @typedef {import("@farjs/blessed").Widgets.Types.TStyle} BlessedStyle | ||
| * @typedef {import("@farjs/blessed").Widgets.BlessedElement} BlessedElement | ||
| * @typedef {import("@farjs/blessed").Widgets.Events.IMouseEventArg} MouseEvent | ||
| * @typedef {import("./ListView").ListViewProps} ListViewProps | ||
| */ | ||
| import React, { useLayoutEffect, useRef } from "react"; | ||
| import * as UI from "./UI.mjs"; | ||
| import UiString from "./UiString.mjs"; | ||
| const h = React.createElement; | ||
| /** | ||
| * @param {number} selected | ||
| * @param {string[]} items | ||
| * @param {number} width | ||
| * @param {BlessedStyle} theme | ||
| * @returns {string[]} | ||
| */ | ||
| function renderItems(selected, items, width, theme) { | ||
| return items.map((item, index) => { | ||
| /** @type {BlessedStyle | undefined} */ | ||
| const style = selected === index ? theme.focus : theme; | ||
| const text = UiString( | ||
| item.replace("\n", "").replace("\r", "").replace("\t", " ") | ||
| ); | ||
| return UI.renderText2(style, text.ensureWidth(width, " ")); | ||
| }); | ||
| } | ||
| /** | ||
| * @param {ListViewProps} props | ||
| */ | ||
| const ListView = (props) => { | ||
| const elementRef = /** @type {React.MutableRefObject<BlessedElement>} */ ( | ||
| useRef() | ||
| ); | ||
| const viewport = props.viewport; | ||
| const offset = viewport.offset; | ||
| const focused = viewport.focused; | ||
| const length = viewport.length; | ||
| const viewLength = viewport.viewLength; | ||
| const itemsContent = renderItems( | ||
| focused, | ||
| props.items.slice(offset, offset + viewLength), | ||
| props.width, | ||
| props.style | ||
| ).join("\n"); | ||
| useLayoutEffect(() => { | ||
| props.setViewport(viewport.resize(props.height)); | ||
| }, [props.height]); | ||
| return h("text", { | ||
| ref: elementRef, | ||
| clickable: true, | ||
| mouse: true, | ||
| autoFocus: false, | ||
| left: props.left, | ||
| top: props.top, | ||
| width: props.width, | ||
| height: props.height, | ||
| style: props.style, | ||
| tags: true, | ||
| wrap: false, | ||
| content: itemsContent, | ||
| onWheelup: () => { | ||
| props.setViewport(viewport.up()); | ||
| }, | ||
| onWheeldown: () => { | ||
| props.setViewport(viewport.down()); | ||
| }, | ||
| onClick: (/** @type {MouseEvent}*/ data) => { | ||
| const curr = elementRef.current; | ||
| const y = data.y - /** @type {number} */ (curr.atop); | ||
| const index = offset + y; | ||
| if (index < length) { | ||
| props.onClick(index); | ||
| } | ||
| }, | ||
| }); | ||
| }; | ||
| ListView.displayName = "ListView"; | ||
| export default ListView; |
| export interface ListPopupProps { | ||
| readonly title: string; | ||
| readonly items: string[]; | ||
| onAction(index: number): void; | ||
| onClose(): void; | ||
| readonly selected?: number; | ||
| onSelect?(index: number): void; | ||
| onKeypress?(keyFull: string): boolean; | ||
| readonly footer?: string; | ||
| readonly textPaddingLeft?: number; | ||
| readonly textPaddingRight?: number; | ||
| readonly itemWrapPrefixLen?: number; | ||
| } |
| /** | ||
| * @typedef {import("./ModalContent").BlessedPadding} BlessedPadding | ||
| * @typedef {import("./ListPopup").ListPopupProps} ListPopupProps | ||
| */ | ||
| import React from "react"; | ||
| import Theme from "../theme/Theme.mjs"; | ||
| import Popup from "./Popup.mjs"; | ||
| import ModalContent from "./ModalContent.mjs"; | ||
| import WithSize from "../WithSize.mjs"; | ||
| import ListBox from "../ListBox.mjs"; | ||
| import TextLine from "../TextLine.mjs"; | ||
| const h = React.createElement; | ||
| /** | ||
| * @param {ListPopupProps} props | ||
| */ | ||
| const ListPopup = (props) => { | ||
| const { popupComp, modalContentComp, withSizeComp, listBoxComp } = ListPopup; | ||
| const items = props.items; | ||
| const theme = Theme.useTheme().popup.menu; | ||
| const textPaddingLeft = props.textPaddingLeft ?? 2; | ||
| const textPaddingRight = props.textPaddingRight ?? 1; | ||
| const itemWrapPrefixLen = props.itemWrapPrefixLen ?? 3; | ||
| const textPaddingLen = textPaddingLeft + textPaddingRight; | ||
| const textPaddingLeftStr = " ".repeat(textPaddingLeft); | ||
| const textPaddingRightStr = " ".repeat(textPaddingRight); | ||
| return h( | ||
| popupComp, | ||
| { | ||
| onClose: props.onClose, | ||
| onKeypress: props.onKeypress, | ||
| }, | ||
| h(withSizeComp, { | ||
| render: (width, height) => { | ||
| const maxContentWidth = | ||
| items.length === 0 | ||
| ? 2 * (ListPopup.paddingHorizontal + 1) | ||
| : items.reduce((_1, _2) => Math.max(_1, _2.length), 0) + | ||
| 2 * (ListPopup.paddingHorizontal + 1); | ||
| const maxContentHeight = | ||
| items.length + 2 * (ListPopup.paddingVertical + 1); | ||
| const modalWidth = Math.min( | ||
| Math.max(minWidth, maxContentWidth + textPaddingLen), | ||
| Math.max(minWidth, width) | ||
| ); | ||
| const modalHeight = Math.min( | ||
| Math.max(minHeight, maxContentHeight), | ||
| Math.max(minHeight, height - 4) | ||
| ); | ||
| const contentWidth = modalWidth - 2 * (ListPopup.paddingHorizontal + 1); // padding + border | ||
| const contentHeight = modalHeight - 2 * (ListPopup.paddingVertical + 1); | ||
| return h( | ||
| modalContentComp, | ||
| { | ||
| title: props.title, | ||
| width: modalWidth, | ||
| height: modalHeight, | ||
| style: theme, | ||
| padding: ListPopup.padding, | ||
| footer: props.footer, | ||
| }, | ||
| h(listBoxComp, { | ||
| left: 1, | ||
| top: 1, | ||
| width: contentWidth, | ||
| height: contentHeight, | ||
| selected: props.selected ?? 0, | ||
| items: items.map((item) => { | ||
| return ( | ||
| textPaddingLeftStr + | ||
| TextLine.wrapText( | ||
| item, | ||
| contentWidth - textPaddingLen, | ||
| itemWrapPrefixLen | ||
| ) + | ||
| textPaddingRightStr | ||
| ); | ||
| }), | ||
| style: theme, | ||
| onAction: (index) => { | ||
| if (items.length > 0) { | ||
| props.onAction(index); | ||
| } | ||
| }, | ||
| onSelect: props.onSelect, | ||
| }) | ||
| ); | ||
| }, | ||
| }) | ||
| ); | ||
| }; | ||
| ListPopup.displayName = "ListPopup"; | ||
| ListPopup.popupComp = Popup; | ||
| ListPopup.modalContentComp = ModalContent; | ||
| ListPopup.withSizeComp = WithSize; | ||
| ListPopup.listBoxComp = ListBox; | ||
| ListPopup.paddingHorizontal = 2; | ||
| ListPopup.paddingVertical = 1; | ||
| const minWidth = 50 + 2 * (ListPopup.paddingHorizontal + 1); // padding + border | ||
| const minHeight = 10 + 2 * (ListPopup.paddingVertical + 1); | ||
| /** @type {BlessedPadding} */ | ||
| ListPopup.padding = { | ||
| left: ListPopup.paddingHorizontal, | ||
| right: ListPopup.paddingHorizontal, | ||
| top: ListPopup.paddingVertical, | ||
| bottom: ListPopup.paddingVertical, | ||
| }; | ||
| export default ListPopup; |
| /// <reference types="node" /> | ||
| import { Widgets } from "@farjs/blessed"; | ||
| export interface ScrollBarProps { | ||
| readonly left: number; | ||
| readonly top: number; | ||
| readonly length: number; | ||
| readonly style: Widgets.Types.TStyle; | ||
| readonly value: number; | ||
| readonly extent: number; | ||
| readonly min: number; | ||
| readonly max: number; | ||
| onChange(value: number): void; | ||
| } |
| /** | ||
| * @typedef {import("./ScrollBar").ScrollBarProps} ScrollBarProps | ||
| */ | ||
| import React from "react"; | ||
| const h = React.createElement; | ||
| /** | ||
| * @param {ScrollBarProps} props | ||
| */ | ||
| const ScrollBar = (props) => { | ||
| const unitIncrement = 1; | ||
| const blockIncrement = Math.max(props.extent, 1); | ||
| const barLength = Math.max(props.length, 2) - 2; | ||
| const min = Math.max(props.min, 0); | ||
| const max = Math.max(props.max, 0); | ||
| const value = Math.min(Math.max(props.value, min), max); | ||
| const markerLength = 1; | ||
| const upLength = | ||
| value === min | ||
| ? 0 | ||
| : value === max | ||
| ? barLength - markerLength | ||
| : Math.max( | ||
| Math.min( | ||
| Math.trunc((value * barLength) / Math.max(max - min, 1)), | ||
| barLength - markerLength - 1 | ||
| ), | ||
| 1 | ||
| ); | ||
| const downLength = barLength - upLength - markerLength; | ||
| return h( | ||
| React.Fragment, | ||
| null, | ||
| h("text", { | ||
| width: 1, | ||
| height: 1, | ||
| left: props.left, | ||
| top: props.top, | ||
| clickable: true, | ||
| mouse: true, | ||
| autoFocus: false, | ||
| style: props.style, | ||
| onClick: () => { | ||
| props.onChange(Math.max(props.value - unitIncrement, min)); | ||
| }, | ||
| content: ScrollBar.upArrowCh, | ||
| }), | ||
| h("text", { | ||
| width: 1, | ||
| height: upLength, | ||
| left: props.left, | ||
| top: props.top + 1, | ||
| clickable: true, | ||
| mouse: true, | ||
| autoFocus: false, | ||
| style: props.style, | ||
| onClick: () => { | ||
| props.onChange(Math.max(props.value - blockIncrement, min)); | ||
| }, | ||
| content: ScrollBar.scrollCh.repeat(upLength), | ||
| }), | ||
| h("text", { | ||
| width: 1, | ||
| height: markerLength, | ||
| left: props.left, | ||
| top: props.top + 1 + upLength, | ||
| autoFocus: false, | ||
| style: props.style, | ||
| content: ScrollBar.markerCh, | ||
| }), | ||
| h("text", { | ||
| width: 1, | ||
| height: downLength, | ||
| left: props.left, | ||
| top: props.top + 1 + upLength + markerLength, | ||
| clickable: true, | ||
| mouse: true, | ||
| autoFocus: false, | ||
| style: props.style, | ||
| onClick: () => { | ||
| props.onChange(Math.min(props.value + blockIncrement, max)); | ||
| }, | ||
| content: ScrollBar.scrollCh.repeat(downLength), | ||
| }), | ||
| h("text", { | ||
| width: 1, | ||
| height: 1, | ||
| left: props.left, | ||
| top: props.top + 1 + upLength + markerLength + downLength, | ||
| clickable: true, | ||
| mouse: true, | ||
| autoFocus: false, | ||
| style: props.style, | ||
| onClick: () => { | ||
| props.onChange(Math.min(props.value + unitIncrement, max)); | ||
| }, | ||
| content: ScrollBar.downArrowCh, | ||
| }) | ||
| ); | ||
| }; | ||
| ScrollBar.displayName = "ScrollBar"; | ||
| ScrollBar.markerCh = "\u2588"; // █ | ||
| ScrollBar.scrollCh = "\u2591"; // ░ | ||
| ScrollBar.upArrowCh = "\u25b2"; // ▲ | ||
| ScrollBar.downArrowCh = "\u25bc"; // ▼ | ||
| export default ScrollBar; |
+1
-1
| { | ||
| "name": "@farjs/ui", | ||
| "author": "viktor-podzigun", | ||
| "version": "0.6.0", | ||
| "version": "0.6.1", | ||
| "license": "MIT", | ||
@@ -6,0 +6,0 @@ "description": "Terminal UI React.js components library", |
+1
-1
| /// <reference types="node" /> | ||
| import { Widgets } from "blessed"; | ||
| import { Widgets } from "@farjs/blessed"; | ||
@@ -5,0 +5,0 @@ export interface ButtonProps { |
@@ -1,2 +0,2 @@ | ||
| import { Widgets } from "blessed"; | ||
| import { Widgets } from "@farjs/blessed"; | ||
@@ -3,0 +3,0 @@ export interface ButtonsPanelAction { |
87832
16.36%89
9.88%3110
15.57%