|
| 1 | +/** |
| 2 | + * @typedef {import("@farjs/blessed").BlessedProgram} BlessedProgram |
| 3 | + * @typedef {import("@farjs/blessed").Widgets.BlessedElement} BlessedElement |
| 4 | + * @typedef {import("./ListViewport").ListViewport} ListViewport |
| 5 | + * @typedef {import("./TextInput").TextInputState} TextInputState |
| 6 | + * @typedef {import("./ComboBox").ComboBoxProps} ComboBoxProps |
| 7 | + */ |
| 8 | +import React, { useRef, useState } from "react"; |
| 9 | +import { createListViewport } from "./ListViewport.mjs"; |
| 10 | +import PopupOverlay from "./popup/PopupOverlay.mjs"; |
| 11 | +import Theme from "./theme/Theme.mjs"; |
| 12 | +import TextInput from "./TextInput.mjs"; |
| 13 | +import ComboBoxPopup from "./ComboBoxPopup.mjs"; |
| 14 | + |
| 15 | +const h = React.createElement; |
| 16 | + |
| 17 | +/** |
| 18 | + * @param {ComboBoxProps} props |
| 19 | + */ |
| 20 | +const ComboBox = (props) => { |
| 21 | + const { textInputComp, comboBoxPopup } = ComboBox; |
| 22 | + |
| 23 | + const inputRef = /** @type {React.MutableRefObject<BlessedElement>} */ ( |
| 24 | + useRef() |
| 25 | + ); |
| 26 | + const programRef = |
| 27 | + /** @type {React.MutableRefObject<BlessedProgram | null>} */ (useRef(null)); |
| 28 | + const autoCompleteTimeoutRef = |
| 29 | + /** @type {React.MutableRefObject<NodeJS.Timeout | null>} */ (useRef(null)); |
| 30 | + |
| 31 | + const [maybePopup, setPopup] = useState( |
| 32 | + /** @type {ListViewport | null} */ (null) |
| 33 | + ); |
| 34 | + const [state, setState] = useState( |
| 35 | + /** @type {TextInputState} */ (TextInput.createState()) |
| 36 | + ); |
| 37 | + const currTheme = Theme.useTheme(); |
| 38 | + const theme = currTheme.popup.menu; |
| 39 | + const arrowStyle = currTheme.popup.regular; |
| 40 | + |
| 41 | + function showOrHidePopup() { |
| 42 | + if (maybePopup) hidePopup(); |
| 43 | + else { |
| 44 | + showPopup( |
| 45 | + createListViewport(0, props.items.length, ComboBoxPopup.maxItems) |
| 46 | + ); |
| 47 | + } |
| 48 | + } |
| 49 | + |
| 50 | + /** @type {(viewport: ListViewport) => void} */ |
| 51 | + function showPopup(viewport) { |
| 52 | + setPopup(viewport); |
| 53 | + |
| 54 | + if (programRef.current) { |
| 55 | + programRef.current.hideCursor(); |
| 56 | + } |
| 57 | + } |
| 58 | + |
| 59 | + function hidePopup() { |
| 60 | + setPopup(null); |
| 61 | + |
| 62 | + if (programRef.current) { |
| 63 | + programRef.current.showCursor(); |
| 64 | + } |
| 65 | + } |
| 66 | + |
| 67 | + /** @type {(offset: number, index: number) => void} */ |
| 68 | + function onSelectAction(offset, index) { |
| 69 | + if (props.items.length > 0) { |
| 70 | + props.onChange(props.items[offset + index]); |
| 71 | + hidePopup(); |
| 72 | + |
| 73 | + process.stdin.emit("keypress", undefined, { |
| 74 | + name: "end", |
| 75 | + ctrl: false, |
| 76 | + meta: false, |
| 77 | + shift: false, |
| 78 | + }); |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + /** @type {(key: string) => void} */ |
| 83 | + function onAutoCompleteAction(key) { |
| 84 | + const value = |
| 85 | + state.selStart !== -1 |
| 86 | + ? props.value.slice(0, Math.min(state.selStart, props.value.length)) |
| 87 | + : props.value; |
| 88 | + |
| 89 | + const newValue = (() => { |
| 90 | + if (key.length === 1) return `${value}${key}`; |
| 91 | + if (key.startsWith("S-") && key.length > 2) { |
| 92 | + return `${value}${key.slice(2).toUpperCase()}`; |
| 93 | + } |
| 94 | + if (key === "space") return `${value} `; |
| 95 | + return value; |
| 96 | + })(); |
| 97 | + |
| 98 | + if (newValue !== value) { |
| 99 | + if (autoCompleteTimeoutRef.current) { |
| 100 | + global.clearTimeout(autoCompleteTimeoutRef.current); |
| 101 | + autoCompleteTimeoutRef.current = null; |
| 102 | + } |
| 103 | + |
| 104 | + const existing = props.items.find((_) => _.startsWith(newValue)); |
| 105 | + if (existing) { |
| 106 | + autoCompleteTimeoutRef.current = global.setTimeout(() => { |
| 107 | + props.onChange(existing); |
| 108 | + |
| 109 | + process.stdin.emit("keypress", undefined, { |
| 110 | + name: "end", |
| 111 | + ctrl: false, |
| 112 | + meta: false, |
| 113 | + shift: true, |
| 114 | + }); |
| 115 | + }, 25); |
| 116 | + } |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + /** @type {(keyFull: string) => boolean} */ |
| 121 | + const onKeypress = (keyFull) => { |
| 122 | + let processed = !!maybePopup; |
| 123 | + switch (keyFull) { |
| 124 | + case "escape": |
| 125 | + case "tab": |
| 126 | + hidePopup(); |
| 127 | + break; |
| 128 | + case "C-up": |
| 129 | + case "C-down": |
| 130 | + showOrHidePopup(); |
| 131 | + processed = true; |
| 132 | + break; |
| 133 | + case "return": |
| 134 | + if (maybePopup) { |
| 135 | + onSelectAction(maybePopup.offset, maybePopup.focused); |
| 136 | + } |
| 137 | + break; |
| 138 | + default: |
| 139 | + if (maybePopup) { |
| 140 | + const vp = maybePopup.onKeypress(keyFull); |
| 141 | + if (vp) { |
| 142 | + setPopup(vp); |
| 143 | + } |
| 144 | + } else onAutoCompleteAction(keyFull); |
| 145 | + break; |
| 146 | + } |
| 147 | + |
| 148 | + return processed; |
| 149 | + }; |
| 150 | + |
| 151 | + return h( |
| 152 | + React.Fragment, |
| 153 | + null, |
| 154 | + |
| 155 | + h(textInputComp, { |
| 156 | + inputRef: inputRef, |
| 157 | + left: props.left, |
| 158 | + top: props.top, |
| 159 | + width: props.width, |
| 160 | + value: props.value, |
| 161 | + state, |
| 162 | + stateUpdater: setState, |
| 163 | + onChange: props.onChange, |
| 164 | + onEnter: props.onEnter, |
| 165 | + onKeypress, |
| 166 | + }), |
| 167 | + |
| 168 | + h("text", { |
| 169 | + width: 1, |
| 170 | + height: 1, |
| 171 | + left: props.left + props.width, |
| 172 | + top: props.top, |
| 173 | + clickable: true, |
| 174 | + mouse: true, |
| 175 | + autoFocus: false, |
| 176 | + style: arrowStyle, |
| 177 | + onClick: () => { |
| 178 | + const el = inputRef.current; |
| 179 | + if (el && el.screen.focused !== el) { |
| 180 | + el.focus(); |
| 181 | + } |
| 182 | + showOrHidePopup(); |
| 183 | + }, |
| 184 | + content: ComboBox.arrowDownCh, |
| 185 | + }), |
| 186 | + |
| 187 | + maybePopup |
| 188 | + ? h( |
| 189 | + "form", |
| 190 | + { |
| 191 | + /** @type {(el?: BlessedElement) => void} */ |
| 192 | + ref: (el) => { |
| 193 | + if (el) { |
| 194 | + programRef.current = el.screen.program; |
| 195 | + } |
| 196 | + }, |
| 197 | + clickable: true, |
| 198 | + mouse: true, |
| 199 | + autoFocus: false, |
| 200 | + style: PopupOverlay.style, |
| 201 | + onClick: hidePopup, |
| 202 | + }, |
| 203 | + h(comboBoxPopup, { |
| 204 | + left: props.left, |
| 205 | + top: props.top + 1, |
| 206 | + width: props.width, |
| 207 | + items: props.items, |
| 208 | + viewport: maybePopup, |
| 209 | + setViewport: setPopup, |
| 210 | + style: theme, |
| 211 | + onClick: (index) => { |
| 212 | + onSelectAction(0, index); |
| 213 | + }, |
| 214 | + }) |
| 215 | + ) |
| 216 | + : null |
| 217 | + ); |
| 218 | +}; |
| 219 | + |
| 220 | +ComboBox.displayName = "ComboBox"; |
| 221 | +ComboBox.textInputComp = TextInput; |
| 222 | +ComboBox.comboBoxPopup = ComboBoxPopup; |
| 223 | + |
| 224 | +ComboBox.arrowDownCh = "\u2193"; // ↓ |
| 225 | + |
| 226 | +export default ComboBox; |
0 commit comments